mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-03-27 12:44:13 +00:00
refactor(monorepo): restructure repo with core, website, vault, os packages
- add monorepo Makefile delegating to sub-projects - update CI workflows, GoReleaser, gitignore for new structure - revise README, CONTRIBUTING.md for monorepo overview - bump Go to 1.24
This commit is contained in:
parent
ebaf37e9d0
commit
abcc23c4f3
3
.github/workflows/release-apt.yml
vendored
3
.github/workflows/release-apt.yml
vendored
@ -46,6 +46,7 @@ jobs:
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Build binary
|
||||
working-directory: core
|
||||
env:
|
||||
GOARCH: ${{ matrix.arch }}
|
||||
CGO_ENABLED: 0
|
||||
@ -71,7 +72,7 @@ jobs:
|
||||
mkdir -p ${PKG_NAME}/usr/local/bin
|
||||
|
||||
# Copy binaries
|
||||
cp build/usr/local/bin/* ${PKG_NAME}/usr/local/bin/
|
||||
cp core/build/usr/local/bin/* ${PKG_NAME}/usr/local/bin/
|
||||
chmod 755 ${PKG_NAME}/usr/local/bin/*
|
||||
|
||||
# Create control file
|
||||
|
||||
2
.github/workflows/release.yaml
vendored
2
.github/workflows/release.yaml
vendored
@ -23,7 +23,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version: '1.24'
|
||||
cache: true
|
||||
|
||||
- name: Run GoReleaser
|
||||
|
||||
149
.gitignore
vendored
149
.gitignore
vendored
@ -1,56 +1,4 @@
|
||||
# Binaries
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
bin/
|
||||
bin-linux/
|
||||
dist/
|
||||
orama-cli-linux
|
||||
|
||||
# Build artifacts
|
||||
*.deb
|
||||
*.rpm
|
||||
*.tar.gz
|
||||
*.zip
|
||||
|
||||
# Go
|
||||
go.work
|
||||
.gocache/
|
||||
|
||||
# Dependencies
|
||||
# vendor/
|
||||
|
||||
# Environment & credentials
|
||||
.env
|
||||
.env.*
|
||||
.env.local
|
||||
.env.*.local
|
||||
scripts/remote-nodes.conf
|
||||
keys_backup/
|
||||
e2e/config.yaml
|
||||
|
||||
# Config (generated/local)
|
||||
configs/
|
||||
|
||||
# Data & databases
|
||||
data/*
|
||||
*.db
|
||||
|
||||
# IDE & editor files
|
||||
.vscode/
|
||||
.idea/
|
||||
.cursor/
|
||||
.claude/
|
||||
.mcp.json
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
# === Global ===
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
@ -58,39 +6,80 @@ data/*
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
.cursor/
|
||||
|
||||
# Environment & credentials
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.mcp.json
|
||||
.claude/
|
||||
.codex/
|
||||
|
||||
# === Core (Go) ===
|
||||
core/phantom-auth/
|
||||
core/bin/
|
||||
core/bin-linux/
|
||||
core/dist/
|
||||
core/orama-cli-linux
|
||||
core/keys_backup/
|
||||
core/.gocache/
|
||||
core/configs/
|
||||
core/data/*
|
||||
core/tmp/
|
||||
core/temp/
|
||||
core/results/
|
||||
core/rnd/
|
||||
core/vps.txt
|
||||
core/coverage.txt
|
||||
core/coverage.html
|
||||
core/profile.out
|
||||
core/e2e/config.yaml
|
||||
core/scripts/remote-nodes.conf
|
||||
|
||||
# Go build artifacts
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
*.test
|
||||
*.out
|
||||
*.deb
|
||||
*.rpm
|
||||
*.tar.gz
|
||||
*.zip
|
||||
go.work
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
# Databases
|
||||
*.db
|
||||
|
||||
# Coverage & profiling
|
||||
coverage.txt
|
||||
coverage.html
|
||||
profile.out
|
||||
# === Website ===
|
||||
website/node_modules/
|
||||
website/dist/
|
||||
website/invest-api/invest-api
|
||||
website/invest-api/*.db
|
||||
website/invest-api/*.db-shm
|
||||
website/invest-api/*.db-wal
|
||||
|
||||
# Local development
|
||||
# === Vault (Zig) ===
|
||||
vault/.zig-cache/
|
||||
vault/zig-out/
|
||||
|
||||
# === OS ===
|
||||
os/output/
|
||||
|
||||
# === Local development ===
|
||||
.dev/
|
||||
.local/
|
||||
local/
|
||||
.codex/
|
||||
results/
|
||||
rnd/
|
||||
vps.txt
|
||||
|
||||
# Project subdirectories (managed separately)
|
||||
website/
|
||||
phantom-auth/
|
||||
|
||||
# One-off scripts & tools
|
||||
redeploy-6.sh
|
||||
terms-agreement
|
||||
./bootstrap
|
||||
./node
|
||||
./cli
|
||||
./inspector
|
||||
docs/later_todos/
|
||||
sim/
|
||||
@ -9,11 +9,13 @@ env:
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- cmd: go mod tidy
|
||||
dir: core
|
||||
|
||||
builds:
|
||||
# orama CLI binary
|
||||
- id: orama
|
||||
dir: core
|
||||
main: ./cmd/cli
|
||||
binary: orama
|
||||
goos:
|
||||
@ -31,6 +33,7 @@ builds:
|
||||
|
||||
# orama-node binary (Linux only for apt)
|
||||
- id: orama-node
|
||||
dir: core
|
||||
main: ./cmd/node
|
||||
binary: orama-node
|
||||
goos:
|
||||
@ -84,7 +87,7 @@ nfpms:
|
||||
section: utils
|
||||
priority: optional
|
||||
contents:
|
||||
- src: ./README.md
|
||||
- src: ./core/README.md
|
||||
dst: /usr/share/doc/orama/README.md
|
||||
deb:
|
||||
lintian_overrides:
|
||||
@ -106,7 +109,7 @@ nfpms:
|
||||
section: net
|
||||
priority: optional
|
||||
contents:
|
||||
- src: ./README.md
|
||||
- src: ./core/README.md
|
||||
dst: /usr/share/doc/orama-node/README.md
|
||||
deb:
|
||||
lintian_overrides:
|
||||
|
||||
@ -1,47 +1,78 @@
|
||||
# Contributing to DeBros Network
|
||||
# Contributing to Orama Network
|
||||
|
||||
Thanks for helping improve the network! This guide covers setup, local dev, tests, and PR guidelines.
|
||||
Thanks for helping improve the network! This monorepo contains multiple projects — pick the one relevant to your contribution.
|
||||
|
||||
## Requirements
|
||||
## Repository Structure
|
||||
|
||||
- Go 1.22+ (1.23 recommended)
|
||||
- RQLite (optional for local runs; the Makefile starts nodes with embedded setup)
|
||||
- Make (optional)
|
||||
| Package | Language | Build |
|
||||
|---------|----------|-------|
|
||||
| `core/` | Go 1.24+ | `make core-build` |
|
||||
| `website/` | TypeScript (pnpm) | `make website-build` |
|
||||
| `vault/` | Zig 0.14+ | `make vault-build` |
|
||||
| `os/` | Go + Buildroot | `make os-build` |
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/DeBrosOfficial/network.git
|
||||
cd network
|
||||
make deps
|
||||
```
|
||||
|
||||
## Build, Test, Lint
|
||||
|
||||
- Build: `make build`
|
||||
- Test: `make test`
|
||||
- Format/Vet: `make fmt vet` (or `make lint`)
|
||||
|
||||
````
|
||||
|
||||
Useful CLI commands:
|
||||
### Core (Go)
|
||||
|
||||
```bash
|
||||
./bin/orama health
|
||||
./bin/orama peers
|
||||
./bin/orama status
|
||||
````
|
||||
cd core
|
||||
make deps
|
||||
make build
|
||||
make test
|
||||
```
|
||||
|
||||
## Versioning
|
||||
### Website
|
||||
|
||||
- The CLI reports its version via `orama version`.
|
||||
- Releases are tagged (e.g., `v0.18.0-beta`) and published via GoReleaser.
|
||||
```bash
|
||||
cd website
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### Vault (Zig)
|
||||
|
||||
```bash
|
||||
cd vault
|
||||
zig build
|
||||
zig build test
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
1. Fork and create a topic branch.
|
||||
2. Ensure `make build test` passes; include tests for new functionality.
|
||||
3. Keep PRs focused and well-described (motivation, approach, testing).
|
||||
4. Update README/docs for behavior changes.
|
||||
1. Fork and create a topic branch from `main`.
|
||||
2. Ensure `make test` passes for affected packages.
|
||||
3. Include tests for new functionality or bug fixes.
|
||||
4. Keep PRs focused — one concern per PR.
|
||||
5. Write a clear description: motivation, approach, and how you tested it.
|
||||
6. Update docs if you're changing user-facing behavior.
|
||||
|
||||
## Code Style
|
||||
|
||||
### Go (core/, os/)
|
||||
|
||||
- Follow standard Go conventions
|
||||
- Run `make lint` before submitting
|
||||
- Wrap errors with context: `fmt.Errorf("failed to X: %w", err)`
|
||||
- No magic values — use named constants
|
||||
|
||||
### TypeScript (website/)
|
||||
|
||||
- TypeScript strict mode
|
||||
- Follow existing patterns in the codebase
|
||||
|
||||
### Zig (vault/)
|
||||
|
||||
- Follow standard Zig conventions
|
||||
- Run `zig build test` before submitting
|
||||
|
||||
## Security
|
||||
|
||||
If you find a security vulnerability, **do not open a public issue**. Email security@debros.io instead.
|
||||
|
||||
Thank you for contributing!
|
||||
|
||||
56
Makefile
Normal file
56
Makefile
Normal file
@ -0,0 +1,56 @@
|
||||
# Orama Monorepo
|
||||
# Delegates to sub-project Makefiles
|
||||
|
||||
.PHONY: help build test clean
|
||||
|
||||
# === Core (Go network) ===
|
||||
.PHONY: core core-build core-test core-clean core-lint
|
||||
core: core-build
|
||||
|
||||
core-build:
|
||||
$(MAKE) -C core build
|
||||
|
||||
core-test:
|
||||
$(MAKE) -C core test
|
||||
|
||||
core-lint:
|
||||
$(MAKE) -C core lint
|
||||
|
||||
core-clean:
|
||||
$(MAKE) -C core clean
|
||||
|
||||
# === Website ===
|
||||
.PHONY: website website-dev website-build
|
||||
website-dev:
|
||||
cd website && pnpm dev
|
||||
|
||||
website-build:
|
||||
cd website && pnpm build
|
||||
|
||||
# === Vault (Zig) ===
|
||||
.PHONY: vault vault-build vault-test
|
||||
vault-build:
|
||||
cd vault && zig build
|
||||
|
||||
vault-test:
|
||||
cd vault && zig build test
|
||||
|
||||
# === OS ===
|
||||
.PHONY: os os-build
|
||||
os-build:
|
||||
$(MAKE) -C os
|
||||
|
||||
# === Aggregate ===
|
||||
build: core-build
|
||||
test: core-test
|
||||
clean: core-clean
|
||||
|
||||
help:
|
||||
@echo "Orama Monorepo"
|
||||
@echo ""
|
||||
@echo " Core (Go): make core-build | core-test | core-lint | core-clean"
|
||||
@echo " Website: make website-dev | website-build"
|
||||
@echo " Vault (Zig): make vault-build | vault-test"
|
||||
@echo " OS: make os-build"
|
||||
@echo ""
|
||||
@echo " Aggregate: make build | test | clean (delegates to core)"
|
||||
484
README.md
484
README.md
@ -1,465 +1,49 @@
|
||||
# Orama Network - Distributed P2P Platform
|
||||
# Orama Network
|
||||
|
||||
A high-performance API Gateway and distributed platform built in Go. Provides a unified HTTP/HTTPS API for distributed SQL (RQLite), distributed caching (Olric), decentralized storage (IPFS), pub/sub messaging, and serverless WebAssembly execution.
|
||||
A decentralized infrastructure platform combining distributed SQL, IPFS storage, caching, serverless WASM execution, and privacy relay — all managed through a unified API gateway.
|
||||
|
||||
**Architecture:** Modular Gateway / Edge Proxy following SOLID principles
|
||||
## Packages
|
||||
|
||||
## Features
|
||||
|
||||
- **🔐 Authentication** - Wallet signatures, API keys, JWT tokens
|
||||
- **💾 Storage** - IPFS-based decentralized file storage with encryption
|
||||
- **⚡ Cache** - Distributed cache with Olric (in-memory key-value)
|
||||
- **🗄️ Database** - RQLite distributed SQL with Raft consensus + Per-namespace SQLite databases
|
||||
- **📡 Pub/Sub** - Real-time messaging via LibP2P and WebSocket
|
||||
- **⚙️ Serverless** - WebAssembly function execution with host functions
|
||||
- **🌐 HTTP Gateway** - Unified REST API with automatic HTTPS (Let's Encrypt)
|
||||
- **📦 Client SDK** - Type-safe Go SDK for all services
|
||||
- **🚀 App Deployments** - Deploy React, Next.js, Go, Node.js apps with automatic domains
|
||||
- **🗄️ SQLite Databases** - Per-namespace isolated databases with IPFS backups
|
||||
|
||||
## Application Deployments
|
||||
|
||||
Deploy full-stack applications with automatic domain assignment and namespace isolation.
|
||||
|
||||
### Deploy a React App
|
||||
|
||||
```bash
|
||||
# Build your app
|
||||
cd my-react-app
|
||||
npm run build
|
||||
|
||||
# Deploy to Orama Network
|
||||
orama deploy static ./dist --name my-app
|
||||
|
||||
# Your app is now live at: https://my-app.orama.network
|
||||
```
|
||||
|
||||
### Deploy Next.js with SSR
|
||||
|
||||
```bash
|
||||
cd my-nextjs-app
|
||||
|
||||
# Ensure next.config.js has: output: 'standalone'
|
||||
npm run build
|
||||
orama deploy nextjs . --name my-nextjs --ssr
|
||||
|
||||
# Live at: https://my-nextjs.orama.network
|
||||
```
|
||||
|
||||
### Deploy Go Backend
|
||||
|
||||
```bash
|
||||
# Build for Linux (name binary 'app' for auto-detection)
|
||||
GOOS=linux GOARCH=amd64 go build -o app main.go
|
||||
|
||||
# Deploy (must implement /health endpoint)
|
||||
orama deploy go ./app --name my-api
|
||||
|
||||
# API live at: https://my-api.orama.network
|
||||
```
|
||||
|
||||
### Create SQLite Database
|
||||
|
||||
```bash
|
||||
# Create database
|
||||
orama db create my-database
|
||||
|
||||
# Create schema
|
||||
orama db query my-database "CREATE TABLE users (id INT, name TEXT)"
|
||||
|
||||
# Insert data
|
||||
orama db query my-database "INSERT INTO users VALUES (1, 'Alice')"
|
||||
|
||||
# Query data
|
||||
orama db query my-database "SELECT * FROM users"
|
||||
|
||||
# Backup to IPFS
|
||||
orama db backup my-database
|
||||
```
|
||||
|
||||
### Full-Stack Example
|
||||
|
||||
Deploy a complete app with React frontend, Go backend, and SQLite database:
|
||||
|
||||
```bash
|
||||
# 1. Create database
|
||||
orama db create myapp-db
|
||||
orama db query myapp-db "CREATE TABLE users (id INT PRIMARY KEY, name TEXT)"
|
||||
|
||||
# 2. Deploy Go backend (connects to database)
|
||||
GOOS=linux GOARCH=amd64 go build -o api main.go
|
||||
orama deploy go ./api --name myapp-api
|
||||
|
||||
# 3. Deploy React frontend (calls backend API)
|
||||
cd frontend && npm run build
|
||||
orama deploy static ./dist --name myapp
|
||||
|
||||
# Access:
|
||||
# Frontend: https://myapp.orama.network
|
||||
# Backend: https://myapp-api.orama.network
|
||||
```
|
||||
|
||||
**📖 Full Guide**: See [Deployment Guide](docs/DEPLOYMENT_GUIDE.md) for complete documentation, examples, and best practices.
|
||||
| Package | Language | Description |
|
||||
|---------|----------|-------------|
|
||||
| [core/](core/) | Go | API gateway, distributed node, CLI, and client SDK |
|
||||
| [website/](website/) | TypeScript | Marketing website and invest portal |
|
||||
| [vault/](vault/) | Zig | Distributed secrets vault (Shamir's Secret Sharing) |
|
||||
| [os/](os/) | Go + Buildroot | OramaOS — hardened minimal Linux for network nodes |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Building
|
||||
|
||||
```bash
|
||||
# Build all binaries
|
||||
make build
|
||||
# Build the core network binaries
|
||||
make core-build
|
||||
|
||||
# Run tests
|
||||
make core-test
|
||||
|
||||
# Start website dev server
|
||||
make website-dev
|
||||
|
||||
# Build vault
|
||||
make vault-build
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
### Authentication
|
||||
|
||||
```bash
|
||||
orama auth login # Authenticate with wallet
|
||||
orama auth status # Check authentication
|
||||
orama auth logout # Clear credentials
|
||||
```
|
||||
|
||||
### Application Deployments
|
||||
|
||||
```bash
|
||||
# Deploy applications
|
||||
orama deploy static <path> --name myapp # React, Vue, static sites
|
||||
orama deploy nextjs <path> --name myapp --ssr # Next.js with SSR (requires output: 'standalone')
|
||||
orama deploy go <path> --name myapp # Go binaries (must have /health endpoint)
|
||||
orama deploy nodejs <path> --name myapp # Node.js apps (must have /health endpoint)
|
||||
|
||||
# Manage deployments
|
||||
orama app list # List all deployments
|
||||
orama app get <name> # Get deployment details
|
||||
orama app logs <name> --follow # View logs
|
||||
orama app delete <name> # Delete deployment
|
||||
orama app rollback <name> --version 1 # Rollback to version
|
||||
```
|
||||
|
||||
### SQLite Databases
|
||||
|
||||
```bash
|
||||
orama db create <name> # Create database
|
||||
orama db query <name> "SELECT * FROM t" # Execute SQL query
|
||||
orama db list # List all databases
|
||||
orama db backup <name> # Backup to IPFS
|
||||
orama db backups <name> # List backups
|
||||
```
|
||||
|
||||
### Environment Management
|
||||
|
||||
```bash
|
||||
orama env list # List available environments
|
||||
orama env current # Show active environment
|
||||
orama env use <name> # Switch environment
|
||||
```
|
||||
|
||||
## Serverless Functions (WASM)
|
||||
|
||||
Orama supports high-performance serverless function execution using WebAssembly (WASM). Functions are isolated, secure, and can interact with network services like the distributed cache.
|
||||
|
||||
> **Full guide:** See [docs/SERVERLESS.md](docs/SERVERLESS.md) for host functions API, secrets management, PubSub triggers, and examples.
|
||||
|
||||
### 1. Build Functions
|
||||
|
||||
Functions must be compiled to WASM. We recommend using [TinyGo](https://tinygo.org/).
|
||||
|
||||
```bash
|
||||
# Build example functions to examples/functions/bin/
|
||||
./examples/functions/build.sh
|
||||
```
|
||||
|
||||
### 2. Deployment
|
||||
|
||||
Deploy your compiled `.wasm` file to the network via the Gateway.
|
||||
|
||||
```bash
|
||||
# Deploy a function
|
||||
curl -X POST https://your-node.example.com/v1/functions \
|
||||
-H "Authorization: Bearer <your_api_key>" \
|
||||
-F "name=hello-world" \
|
||||
-F "namespace=default" \
|
||||
-F "wasm=@./examples/functions/bin/hello.wasm"
|
||||
```
|
||||
|
||||
### 3. Invocation
|
||||
|
||||
Trigger your function with a JSON payload. The function receives the payload via `stdin` and returns its response via `stdout`.
|
||||
|
||||
```bash
|
||||
# Invoke via HTTP
|
||||
curl -X POST https://your-node.example.com/v1/functions/hello-world/invoke \
|
||||
-H "Authorization: Bearer <your_api_key>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Developer"}'
|
||||
```
|
||||
|
||||
### 4. Management
|
||||
|
||||
```bash
|
||||
# List all functions in a namespace
|
||||
curl https://your-node.example.com/v1/functions?namespace=default
|
||||
|
||||
# Delete a function
|
||||
curl -X DELETE https://your-node.example.com/v1/functions/hello-world?namespace=default
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Ubuntu 22.04+ or Debian 12+
|
||||
- `amd64` or `arm64` architecture
|
||||
- 4GB RAM, 50GB SSD, 2 CPU cores
|
||||
|
||||
### Required Ports
|
||||
|
||||
**External (must be open in firewall):**
|
||||
|
||||
- **80** - HTTP (ACME/Let's Encrypt certificate challenges)
|
||||
- **443** - HTTPS (Main gateway API endpoint)
|
||||
- **4101** - IPFS Swarm (peer connections)
|
||||
- **7001** - RQLite Raft (cluster consensus)
|
||||
|
||||
**Internal (bound to localhost, no firewall needed):**
|
||||
|
||||
- 4501 - IPFS API
|
||||
- 5001 - RQLite HTTP API
|
||||
- 6001 - Unified Gateway
|
||||
- 8080 - IPFS Gateway
|
||||
- 9050 - Anyone SOCKS5 proxy
|
||||
- 9094 - IPFS Cluster API
|
||||
- 3320/3322 - Olric Cache
|
||||
|
||||
**Anyone Relay Mode (optional, for earning rewards):**
|
||||
|
||||
- 9001 - Anyone ORPort (relay traffic, must be open externally)
|
||||
|
||||
### Anyone Network Integration
|
||||
|
||||
Orama Network integrates with the [Anyone Protocol](https://anyone.io) for anonymous routing. By default, nodes run as **clients** (consuming the network). Optionally, you can run as a **relay operator** to earn rewards.
|
||||
|
||||
**Client Mode (Default):**
|
||||
- Routes traffic through Anyone network for anonymity
|
||||
- SOCKS5 proxy on localhost:9050
|
||||
- No rewards, just consumes network
|
||||
|
||||
**Relay Mode (Earn Rewards):**
|
||||
- Provide bandwidth to the Anyone network
|
||||
- Earn $ANYONE tokens as a relay operator
|
||||
- Requires 100 $ANYONE tokens in your wallet
|
||||
- Requires ORPort (9001) open to the internet
|
||||
|
||||
```bash
|
||||
# Install as relay operator (earn rewards)
|
||||
sudo orama node install --vps-ip <IP> --domain <domain> \
|
||||
--anyone-relay \
|
||||
--anyone-nickname "MyRelay" \
|
||||
--anyone-contact "operator@email.com" \
|
||||
--anyone-wallet "0x1234...abcd"
|
||||
|
||||
# With exit relay (legal implications apply)
|
||||
sudo orama node install --vps-ip <IP> --domain <domain> \
|
||||
--anyone-relay \
|
||||
--anyone-exit \
|
||||
--anyone-nickname "MyExitRelay" \
|
||||
--anyone-contact "operator@email.com" \
|
||||
--anyone-wallet "0x1234...abcd"
|
||||
|
||||
# Migrate existing Anyone installation
|
||||
sudo orama node install --vps-ip <IP> --domain <domain> \
|
||||
--anyone-relay \
|
||||
--anyone-migrate \
|
||||
--anyone-nickname "MyRelay" \
|
||||
--anyone-contact "operator@email.com" \
|
||||
--anyone-wallet "0x1234...abcd"
|
||||
```
|
||||
|
||||
**Important:** After installation, register your relay at [dashboard.anyone.io](https://dashboard.anyone.io) to start earning rewards.
|
||||
|
||||
### Installation
|
||||
|
||||
**macOS (Homebrew):**
|
||||
|
||||
```bash
|
||||
brew install DeBrosOfficial/tap/orama
|
||||
```
|
||||
|
||||
**Linux (Debian/Ubuntu):**
|
||||
|
||||
```bash
|
||||
# Download and install the latest .deb package
|
||||
curl -sL https://github.com/DeBrosOfficial/network/releases/latest/download/orama_$(curl -s https://api.github.com/repos/DeBrosOfficial/network/releases/latest | grep tag_name | cut -d '"' -f 4 | tr -d 'v')_linux_amd64.deb -o orama.deb
|
||||
sudo dpkg -i orama.deb
|
||||
```
|
||||
|
||||
**From Source:**
|
||||
|
||||
```bash
|
||||
go install github.com/DeBrosOfficial/network/cmd/cli@latest
|
||||
```
|
||||
|
||||
**Setup (after installation):**
|
||||
|
||||
```bash
|
||||
sudo orama node install --interactive
|
||||
```
|
||||
|
||||
### Service Management
|
||||
|
||||
```bash
|
||||
# Status
|
||||
sudo orama node status
|
||||
|
||||
# Control services
|
||||
sudo orama node start
|
||||
sudo orama node stop
|
||||
sudo orama node restart
|
||||
|
||||
# Diagnose issues
|
||||
sudo orama node doctor
|
||||
|
||||
# View logs
|
||||
orama node logs node --follow
|
||||
orama node logs gateway --follow
|
||||
orama node logs ipfs --follow
|
||||
```
|
||||
|
||||
### Upgrade
|
||||
|
||||
```bash
|
||||
# Upgrade to latest version
|
||||
sudo orama node upgrade --restart
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration lives in `~/.orama/`:
|
||||
|
||||
- `configs/node.yaml` - Node configuration
|
||||
- `configs/gateway.yaml` - Gateway configuration
|
||||
- `configs/olric.yaml` - Cache configuration
|
||||
- `secrets/` - Keys and certificates
|
||||
- `data/` - Service data directories
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Services Not Starting
|
||||
|
||||
```bash
|
||||
# Check status
|
||||
sudo orama node status
|
||||
|
||||
# View logs
|
||||
orama node logs node --follow
|
||||
|
||||
# Check log files
|
||||
sudo orama node doctor
|
||||
```
|
||||
|
||||
### Port Conflicts
|
||||
|
||||
```bash
|
||||
# Check what's using specific ports
|
||||
sudo lsof -i :443 # HTTPS Gateway
|
||||
sudo lsof -i :7001 # TCP/SNI Gateway
|
||||
sudo lsof -i :6001 # Internal Gateway
|
||||
```
|
||||
|
||||
### RQLite Cluster Issues
|
||||
|
||||
```bash
|
||||
# Connect to RQLite CLI
|
||||
rqlite -H localhost -p 5001
|
||||
|
||||
# Check cluster status
|
||||
.nodes
|
||||
.status
|
||||
.ready
|
||||
|
||||
# Check consistency level
|
||||
.consistency
|
||||
```
|
||||
|
||||
### Reset Installation
|
||||
|
||||
```bash
|
||||
# Production reset (⚠️ DESTROYS DATA)
|
||||
sudo orama node uninstall
|
||||
sudo rm -rf /opt/orama/.orama
|
||||
sudo orama node install
|
||||
```
|
||||
|
||||
## HTTP Gateway API
|
||||
|
||||
### Main Gateway Endpoints
|
||||
|
||||
- `GET /health` - Health status
|
||||
- `GET /v1/status` - Full status
|
||||
- `GET /v1/version` - Version info
|
||||
- `POST /v1/rqlite/exec` - Execute SQL
|
||||
- `POST /v1/rqlite/query` - Query database
|
||||
- `GET /v1/rqlite/schema` - Get schema
|
||||
- `POST /v1/pubsub/publish` - Publish message
|
||||
- `GET /v1/pubsub/topics` - List topics
|
||||
- `GET /v1/pubsub/ws?topic=<name>` - WebSocket subscribe
|
||||
- `POST /v1/functions` - Deploy function (multipart/form-data)
|
||||
- `POST /v1/functions/{name}/invoke` - Invoke function
|
||||
- `GET /v1/functions` - List functions
|
||||
- `DELETE /v1/functions/{name}` - Delete function
|
||||
- `GET /v1/functions/{name}/logs` - Get function logs
|
||||
|
||||
See `openapi/gateway.yaml` for complete API specification.
|
||||
|
||||
## Documentation
|
||||
|
||||
- **[Deployment Guide](docs/DEPLOYMENT_GUIDE.md)** - Deploy React, Next.js, Go apps and manage databases
|
||||
- **[Architecture Guide](docs/ARCHITECTURE.md)** - System architecture and design patterns
|
||||
- **[Client SDK](docs/CLIENT_SDK.md)** - Go SDK documentation and examples
|
||||
- **[Monitoring](docs/MONITORING.md)** - Cluster monitoring and health checks
|
||||
- **[Inspector](docs/INSPECTOR.md)** - Deep subsystem health inspection
|
||||
- **[Serverless Functions](docs/SERVERLESS.md)** - WASM serverless with host functions
|
||||
- **[WebRTC](docs/WEBRTC.md)** - Real-time communication setup
|
||||
- **[Common Problems](docs/COMMON_PROBLEMS.md)** - Troubleshooting known issues
|
||||
|
||||
## Resources
|
||||
|
||||
- [RQLite Documentation](https://rqlite.io/docs/)
|
||||
- [IPFS Documentation](https://docs.ipfs.tech/)
|
||||
- [LibP2P Documentation](https://docs.libp2p.io/)
|
||||
- [WebAssembly](https://webassembly.org/)
|
||||
- [GitHub Repository](https://github.com/DeBrosOfficial/network)
|
||||
- [Issue Tracker](https://github.com/DeBrosOfficial/network/issues)
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
network/
|
||||
├── cmd/ # Binary entry points
|
||||
│ ├── cli/ # CLI tool
|
||||
│ ├── gateway/ # HTTP Gateway
|
||||
│ ├── node/ # P2P Node
|
||||
├── pkg/ # Core packages
|
||||
│ ├── gateway/ # Gateway implementation
|
||||
│ │ └── handlers/ # HTTP handlers by domain
|
||||
│ ├── client/ # Go SDK
|
||||
│ ├── serverless/ # WASM engine
|
||||
│ ├── rqlite/ # Database ORM
|
||||
│ ├── contracts/ # Interface definitions
|
||||
│ ├── httputil/ # HTTP utilities
|
||||
│ └── errors/ # Error handling
|
||||
├── docs/ # Documentation
|
||||
├── e2e/ # End-to-end tests
|
||||
└── examples/ # Example code
|
||||
```
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Architecture](core/docs/ARCHITECTURE.md) | System architecture and design patterns |
|
||||
| [Deployment Guide](core/docs/DEPLOYMENT_GUIDE.md) | Deploy apps, databases, and domains |
|
||||
| [Dev & Deploy](core/docs/DEV_DEPLOY.md) | Building, deploying to VPS, rolling upgrades |
|
||||
| [Security](core/docs/SECURITY.md) | Security hardening and threat model |
|
||||
| [Monitoring](core/docs/MONITORING.md) | Cluster health monitoring |
|
||||
| [Client SDK](core/docs/CLIENT_SDK.md) | Go SDK documentation |
|
||||
| [Serverless](core/docs/SERVERLESS.md) | WASM serverless functions |
|
||||
| [Common Problems](core/docs/COMMON_PROBLEMS.md) | Troubleshooting known issues |
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! This project follows:
|
||||
- **SOLID Principles** - Single responsibility, open/closed, etc.
|
||||
- **DRY Principle** - Don't repeat yourself
|
||||
- **Clean Architecture** - Clear separation of concerns
|
||||
- **Test Coverage** - Unit and E2E tests required
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for setup, development, and PR guidelines.
|
||||
|
||||
See our architecture docs for design patterns and guidelines.
|
||||
## License
|
||||
|
||||
[AGPL-3.0](LICENSE)
|
||||
|
||||
8
core/.env.example
Normal file
8
core/.env.example
Normal file
@ -0,0 +1,8 @@
|
||||
# OpenRouter API Key for changelog generation
|
||||
# Get your API key from https://openrouter.ai/keys
|
||||
OPENROUTER_API_KEY=your-api-key-here
|
||||
|
||||
# ZeroSSL API Key for TLS certificates (alternative to Let's Encrypt)
|
||||
# Get your free API key from https://app.zerossl.com/developer
|
||||
# If not set, Caddy will use Let's Encrypt as the default CA
|
||||
ZEROSSL_API_KEY=
|
||||
52
os/Makefile
Normal file
52
os/Makefile
Normal file
@ -0,0 +1,52 @@
|
||||
SHELL := /bin/bash
|
||||
.PHONY: agent build sign test clean
|
||||
|
||||
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo "dev")
|
||||
ARCH ?= amd64
|
||||
|
||||
# Directories
|
||||
AGENT_DIR := agent
|
||||
BUILDROOT_DIR := buildroot
|
||||
SCRIPTS_DIR := scripts
|
||||
OUTPUT_DIR := output
|
||||
|
||||
# --- Agent ---
|
||||
|
||||
agent:
|
||||
@echo "=== Building orama-agent ==="
|
||||
cd $(AGENT_DIR) && GOOS=linux GOARCH=$(ARCH) CGO_ENABLED=0 \
|
||||
go build -ldflags "-s -w" -o ../$(OUTPUT_DIR)/orama-agent ./cmd/orama-agent/
|
||||
@echo "Built: $(OUTPUT_DIR)/orama-agent"
|
||||
|
||||
agent-test:
|
||||
@echo "=== Testing orama-agent ==="
|
||||
cd $(AGENT_DIR) && go test ./...
|
||||
|
||||
# --- Full Image Build ---
|
||||
|
||||
build: agent
|
||||
@echo "=== Building OramaOS image ==="
|
||||
ORAMA_VERSION=$(VERSION) ARCH=$(ARCH) $(SCRIPTS_DIR)/build.sh
|
||||
@echo "Build complete: $(OUTPUT_DIR)/"
|
||||
|
||||
# --- Signing ---
|
||||
|
||||
sign:
|
||||
@echo "=== Signing OramaOS image ==="
|
||||
$(SCRIPTS_DIR)/sign.sh $(OUTPUT_DIR)/orama-os-$(VERSION)-$(ARCH)
|
||||
|
||||
# --- QEMU Testing ---
|
||||
|
||||
test: build
|
||||
@echo "=== Launching QEMU test VM ==="
|
||||
$(SCRIPTS_DIR)/test-vm.sh $(OUTPUT_DIR)/orama-os.qcow2
|
||||
|
||||
test-vm:
|
||||
@echo "=== Launching QEMU with existing image ==="
|
||||
$(SCRIPTS_DIR)/test-vm.sh $(OUTPUT_DIR)/orama-os.qcow2
|
||||
|
||||
# --- Clean ---
|
||||
|
||||
clean:
|
||||
rm -rf $(OUTPUT_DIR)
|
||||
@echo "Cleaned output directory"
|
||||
35
os/agent/cmd/orama-agent/main.go
Normal file
35
os/agent/cmd/orama-agent/main.go
Normal file
@ -0,0 +1,35 @@
|
||||
// orama-agent is the sole root process on OramaOS.
|
||||
// It handles enrollment, LUKS key management, service supervision,
|
||||
// over-the-air updates, and command reception.
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/DeBrosOfficial/orama-os/agent/internal/boot"
|
||||
)
|
||||
|
||||
func main() {
|
||||
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
|
||||
log.Println("orama-agent starting")
|
||||
|
||||
agent, err := boot.NewAgent()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize agent: %v", err)
|
||||
}
|
||||
|
||||
if err := agent.Run(); err != nil {
|
||||
log.Fatalf("agent failed: %v", err)
|
||||
}
|
||||
|
||||
// Wait for termination signal
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
|
||||
sig := <-sigCh
|
||||
log.Printf("received %s, shutting down", sig)
|
||||
|
||||
agent.Shutdown()
|
||||
}
|
||||
8
os/agent/go.mod
Normal file
8
os/agent/go.mod
Normal file
@ -0,0 +1,8 @@
|
||||
module github.com/DeBrosOfficial/orama-os/agent
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
)
|
||||
4
os/agent/go.sum
Normal file
4
os/agent/go.sum
Normal file
@ -0,0 +1,4 @@
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
304
os/agent/internal/boot/boot.go
Normal file
304
os/agent/internal/boot/boot.go
Normal file
@ -0,0 +1,304 @@
|
||||
// Package boot orchestrates the OramaOS agent boot sequence.
|
||||
//
|
||||
// Two modes:
|
||||
// - Enrollment mode (first boot): HTTP server on :9999, WG setup, LUKS format, share distribution
|
||||
// - Standard boot (subsequent): WG up, LUKS unlock via Shamir shares, start services
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DeBrosOfficial/orama-os/agent/internal/command"
|
||||
"github.com/DeBrosOfficial/orama-os/agent/internal/enroll"
|
||||
"github.com/DeBrosOfficial/orama-os/agent/internal/health"
|
||||
"github.com/DeBrosOfficial/orama-os/agent/internal/sandbox"
|
||||
"github.com/DeBrosOfficial/orama-os/agent/internal/update"
|
||||
"github.com/DeBrosOfficial/orama-os/agent/internal/wireguard"
|
||||
)
|
||||
|
||||
const (
|
||||
// OramaDir is the base data directory, mounted from the LUKS-encrypted partition.
|
||||
OramaDir = "/opt/orama/.orama"
|
||||
|
||||
// EnrolledFlag indicates that this node has completed enrollment.
|
||||
EnrolledFlag = "/opt/orama/.orama/enrolled"
|
||||
|
||||
// DataDevice is the LUKS-encrypted data partition.
|
||||
DataDevice = "/dev/sda3"
|
||||
|
||||
// DataMapperName is the device-mapper name for the unlocked LUKS partition.
|
||||
DataMapperName = "orama-data"
|
||||
|
||||
// DataMountPoint is where the decrypted data partition is mounted.
|
||||
DataMountPoint = "/opt/orama/.orama"
|
||||
|
||||
// WireGuardConfigPath is the path to the WireGuard configuration baked into rootfs
|
||||
// during enrollment, or written during first boot.
|
||||
WireGuardConfigPath = "/etc/wireguard/wg0.conf"
|
||||
|
||||
// GatewayEndpoint is the default gateway URL for enrollment WebSocket.
|
||||
// Overridden by /etc/orama/gateway-url if present.
|
||||
GatewayEndpoint = "wss://gateway.orama.network/v1/agent/enroll"
|
||||
)
|
||||
|
||||
// Agent is the main orchestrator for the OramaOS node.
|
||||
type Agent struct {
|
||||
wg *wireguard.Manager
|
||||
supervisor *sandbox.Supervisor
|
||||
updater *update.Manager
|
||||
cmdRecv *command.Receiver
|
||||
reporter *health.Reporter
|
||||
|
||||
mu sync.Mutex
|
||||
shutdown bool
|
||||
}
|
||||
|
||||
// NewAgent creates a new Agent instance.
|
||||
func NewAgent() (*Agent, error) {
|
||||
return &Agent{
|
||||
wg: wireguard.NewManager(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Run executes the boot sequence. It detects whether this is a first boot
|
||||
// (enrollment) or a standard boot, and acts accordingly.
|
||||
func (a *Agent) Run() error {
|
||||
if isEnrolled() {
|
||||
return a.standardBoot()
|
||||
}
|
||||
return a.enrollmentBoot()
|
||||
}
|
||||
|
||||
// isEnrolled checks if the node has completed enrollment.
|
||||
func isEnrolled() bool {
|
||||
_, err := os.Stat(EnrolledFlag)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// enrollmentBoot handles first-boot enrollment.
|
||||
func (a *Agent) enrollmentBoot() error {
|
||||
log.Println("ENROLLMENT MODE: first boot detected")
|
||||
|
||||
// 1. Start enrollment server on port 9999
|
||||
enrollServer := enroll.NewServer(resolveGatewayEndpoint())
|
||||
result, err := enrollServer.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("enrollment failed: %w", err)
|
||||
}
|
||||
|
||||
log.Println("enrollment complete, configuring node")
|
||||
|
||||
// 2. Configure WireGuard with received config
|
||||
if err := a.wg.Configure(result.WireGuardConfig); err != nil {
|
||||
return fmt.Errorf("failed to configure WireGuard: %w", err)
|
||||
}
|
||||
if err := a.wg.Up(); err != nil {
|
||||
return fmt.Errorf("failed to bring up WireGuard: %w", err)
|
||||
}
|
||||
|
||||
// 3. Generate LUKS key, format, and encrypt data partition
|
||||
luksKey, err := GenerateLUKSKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate LUKS key: %w", err)
|
||||
}
|
||||
|
||||
if err := FormatAndEncrypt(DataDevice, luksKey); err != nil {
|
||||
ZeroBytes(luksKey)
|
||||
return fmt.Errorf("failed to format LUKS partition: %w", err)
|
||||
}
|
||||
|
||||
// 4. Distribute LUKS key shares to peer vault-guardians
|
||||
if err := DistributeKeyShares(luksKey, result.Peers, result.NodeID); err != nil {
|
||||
ZeroBytes(luksKey)
|
||||
return fmt.Errorf("failed to distribute key shares: %w", err)
|
||||
}
|
||||
ZeroBytes(luksKey)
|
||||
|
||||
// 5. FormatAndEncrypt already mounted the partition — no need to decrypt again.
|
||||
|
||||
// 6. Write enrolled flag
|
||||
if err := os.MkdirAll(filepath.Dir(EnrolledFlag), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create enrolled flag dir: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(EnrolledFlag, []byte("1"), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write enrolled flag: %w", err)
|
||||
}
|
||||
|
||||
log.Println("enrollment complete, proceeding to standard boot")
|
||||
|
||||
// 7. Start services
|
||||
return a.startServices()
|
||||
}
|
||||
|
||||
// standardBoot handles normal reboot sequence.
|
||||
func (a *Agent) standardBoot() error {
|
||||
log.Println("STANDARD BOOT: enrolled node")
|
||||
|
||||
// 1. Bring up WireGuard
|
||||
if err := a.wg.Up(); err != nil {
|
||||
return fmt.Errorf("failed to bring up WireGuard: %w", err)
|
||||
}
|
||||
|
||||
// 2. Try Shamir-based LUKS key reconstruction
|
||||
luksKey, err := FetchAndReconstruct(a.wg)
|
||||
if err != nil {
|
||||
// Shamir failed — fall back to genesis unlock mode.
|
||||
// This happens when the genesis node reboots before enough peers
|
||||
// have joined for Shamir distribution, or when peers are offline.
|
||||
log.Printf("Shamir reconstruction failed: %v", err)
|
||||
log.Println("Entering genesis unlock mode — waiting for operator unlock via WireGuard")
|
||||
|
||||
luksKey, err = a.waitForGenesisUnlock()
|
||||
if err != nil {
|
||||
return fmt.Errorf("genesis unlock failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Decrypt and mount data partition
|
||||
if err := DecryptAndMount(DataDevice, luksKey); err != nil {
|
||||
ZeroBytes(luksKey)
|
||||
return fmt.Errorf("failed to mount data partition: %w", err)
|
||||
}
|
||||
ZeroBytes(luksKey)
|
||||
|
||||
// 4. Mark boot as successful (A/B boot counting)
|
||||
if err := update.MarkBootSuccessful(); err != nil {
|
||||
log.Printf("WARNING: failed to mark boot successful: %v", err)
|
||||
}
|
||||
|
||||
// 5. Start services
|
||||
return a.startServices()
|
||||
}
|
||||
|
||||
// waitForGenesisUnlock starts a temporary HTTP server on the WireGuard interface
|
||||
// (port 9998) that accepts a LUKS key from the operator.
|
||||
// The operator sends: POST /v1/agent/unlock with {"key":"<base64-luks-key>"}
|
||||
func (a *Agent) waitForGenesisUnlock() ([]byte, error) {
|
||||
keyCh := make(chan []byte, 1)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/v1/agent/unlock", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Key string `json:"key"` // base64-encoded LUKS key
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
keyBytes, err := base64.StdEncoding.DecodeString(req.Key)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid base64 key", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if len(keyBytes) != 32 {
|
||||
http.Error(w, "key must be 32 bytes", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "unlocking"})
|
||||
|
||||
keyCh <- keyBytes
|
||||
})
|
||||
|
||||
server := &http.Server{
|
||||
Addr: ":9998",
|
||||
Handler: mux,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
||||
errCh <- fmt.Errorf("genesis unlock server error: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
log.Println("Genesis unlock server listening on :9998")
|
||||
log.Println("Run 'orama node unlock --genesis --node-ip <wg-ip>' to unlock this node")
|
||||
|
||||
select {
|
||||
case key := <-keyCh:
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
server.Shutdown(ctx)
|
||||
return key, nil
|
||||
case err := <-errCh:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// startServices launches all node services in sandboxes and starts background tasks.
|
||||
func (a *Agent) startServices() error {
|
||||
// Start service supervisor
|
||||
a.supervisor = sandbox.NewSupervisor()
|
||||
if err := a.supervisor.StartAll(); err != nil {
|
||||
return fmt.Errorf("failed to start services: %w", err)
|
||||
}
|
||||
|
||||
// Start command receiver (listen for Gateway commands over WG)
|
||||
a.cmdRecv = command.NewReceiver(a.supervisor)
|
||||
go a.cmdRecv.Listen()
|
||||
|
||||
// Start update checker (periodic)
|
||||
a.updater = update.NewManager()
|
||||
go a.updater.RunLoop()
|
||||
|
||||
// Start health reporter (periodic)
|
||||
a.reporter = health.NewReporter(a.supervisor)
|
||||
go a.reporter.RunLoop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown gracefully stops all services.
|
||||
func (a *Agent) Shutdown() {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
if a.shutdown {
|
||||
return
|
||||
}
|
||||
a.shutdown = true
|
||||
|
||||
log.Println("shutting down agent")
|
||||
|
||||
if a.cmdRecv != nil {
|
||||
a.cmdRecv.Stop()
|
||||
}
|
||||
if a.updater != nil {
|
||||
a.updater.Stop()
|
||||
}
|
||||
if a.reporter != nil {
|
||||
a.reporter.Stop()
|
||||
}
|
||||
if a.supervisor != nil {
|
||||
a.supervisor.StopAll()
|
||||
}
|
||||
}
|
||||
|
||||
// resolveGatewayEndpoint reads the gateway URL from config or uses the default.
|
||||
func resolveGatewayEndpoint() string {
|
||||
data, err := os.ReadFile("/etc/orama/gateway-url")
|
||||
if err == nil {
|
||||
return string(data)
|
||||
}
|
||||
return GatewayEndpoint
|
||||
}
|
||||
504
os/agent/internal/boot/luks.go
Normal file
504
os/agent/internal/boot/luks.go
Normal file
@ -0,0 +1,504 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DeBrosOfficial/orama-os/agent/internal/types"
|
||||
"github.com/DeBrosOfficial/orama-os/agent/internal/wireguard"
|
||||
)
|
||||
|
||||
// GenerateLUKSKey generates a cryptographically random 32-byte key for LUKS encryption.
|
||||
func GenerateLUKSKey() ([]byte, error) {
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, fmt.Errorf("failed to read random bytes: %w", err)
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// FormatAndEncrypt formats a device with LUKS2 encryption and creates an ext4 filesystem.
|
||||
func FormatAndEncrypt(device string, key []byte) error {
|
||||
log.Printf("formatting %s with LUKS2", device)
|
||||
|
||||
// cryptsetup luksFormat --type luks2 --cipher aes-xts-plain64 <device> --key-file=-
|
||||
cmd := exec.Command("cryptsetup", "luksFormat", "--type", "luks2",
|
||||
"--cipher", "aes-xts-plain64", "--batch-mode", device, "--key-file=-")
|
||||
cmd.Stdin = bytes.NewReader(key)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("luksFormat failed: %w\n%s", err, string(output))
|
||||
}
|
||||
|
||||
// cryptsetup open <device> orama-data --key-file=-
|
||||
cmd = exec.Command("cryptsetup", "open", device, DataMapperName, "--key-file=-")
|
||||
cmd.Stdin = bytes.NewReader(key)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("cryptsetup open failed: %w\n%s", err, string(output))
|
||||
}
|
||||
|
||||
// mkfs.ext4 /dev/mapper/orama-data
|
||||
cmd = exec.Command("mkfs.ext4", "-F", "/dev/mapper/"+DataMapperName)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("mkfs.ext4 failed: %w\n%s", err, string(output))
|
||||
}
|
||||
|
||||
// Mount
|
||||
if err := os.MkdirAll(DataMountPoint, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create mount point: %w", err)
|
||||
}
|
||||
cmd = exec.Command("mount", "/dev/mapper/"+DataMapperName, DataMountPoint)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("mount failed: %w\n%s", err, string(output))
|
||||
}
|
||||
|
||||
log.Println("LUKS partition formatted and mounted")
|
||||
return nil
|
||||
}
|
||||
|
||||
// DecryptAndMount opens and mounts an existing LUKS partition.
|
||||
func DecryptAndMount(device string, key []byte) error {
|
||||
// cryptsetup open <device> orama-data --key-file=-
|
||||
cmd := exec.Command("cryptsetup", "open", device, DataMapperName, "--key-file=-")
|
||||
cmd.Stdin = bytes.NewReader(key)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("cryptsetup open failed: %w\n%s", err, string(output))
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(DataMountPoint, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create mount point: %w", err)
|
||||
}
|
||||
|
||||
cmd = exec.Command("mount", "/dev/mapper/"+DataMapperName, DataMountPoint)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("mount failed: %w\n%s", err, string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DistributeKeyShares splits the LUKS key into Shamir shares and pushes them
|
||||
// to peer vault-guardians over WireGuard.
|
||||
func DistributeKeyShares(key []byte, peers []types.Peer, nodeID string) error {
|
||||
n := len(peers)
|
||||
if n == 0 {
|
||||
return fmt.Errorf("no peers available for key distribution")
|
||||
}
|
||||
|
||||
// Adaptive threshold: at least 3, or n/3 (whichever is greater)
|
||||
k := int(math.Max(3, float64(n)/3.0))
|
||||
if k > n {
|
||||
k = n
|
||||
}
|
||||
|
||||
log.Printf("splitting LUKS key into %d shares (threshold=%d)", n, k)
|
||||
|
||||
shares, err := shamirSplit(key, n, k)
|
||||
if err != nil {
|
||||
return fmt.Errorf("shamir split failed: %w", err)
|
||||
}
|
||||
|
||||
// Derive agent identity from the node's WG private key
|
||||
identity, err := deriveAgentIdentity()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to derive agent identity: %w", err)
|
||||
}
|
||||
|
||||
for i, peer := range peers {
|
||||
session, err := vaultAuth(peer.WGIP, identity)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to authenticate with peer %s: %w", peer.WGIP, err)
|
||||
}
|
||||
|
||||
shareB64 := base64.StdEncoding.EncodeToString(shares[i])
|
||||
secretName := fmt.Sprintf("luks-key-%s", nodeID)
|
||||
|
||||
if err := vaultPutSecret(peer.WGIP, session, secretName, shareB64, 1); err != nil {
|
||||
return fmt.Errorf("failed to store share on peer %s: %w", peer.WGIP, err)
|
||||
}
|
||||
|
||||
log.Printf("stored share %d/%d on peer %s", i+1, n, peer.WGIP)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FetchAndReconstruct fetches Shamir shares from peers and reconstructs the LUKS key.
|
||||
// Uses exponential backoff: 1s, 2s, 4s, 8s, 16s, max 5 retries.
|
||||
func FetchAndReconstruct(wg *wireguard.Manager) ([]byte, error) {
|
||||
peers, err := loadPeerConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load peer config: %w", err)
|
||||
}
|
||||
|
||||
nodeID, err := loadNodeID()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load node ID: %w", err)
|
||||
}
|
||||
|
||||
identity, err := deriveAgentIdentity()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to derive agent identity: %w", err)
|
||||
}
|
||||
|
||||
n := len(peers)
|
||||
k := int(math.Max(3, float64(n)/3.0))
|
||||
if k > n {
|
||||
k = n
|
||||
}
|
||||
|
||||
secretName := fmt.Sprintf("luks-key-%s", nodeID)
|
||||
|
||||
var shares [][]byte
|
||||
const maxRetries = 5
|
||||
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
delay := time.Duration(1<<uint(attempt-1)) * time.Second
|
||||
log.Printf("retrying share fetch in %v (attempt %d/%d)", delay, attempt, maxRetries)
|
||||
time.Sleep(delay)
|
||||
}
|
||||
|
||||
shares = nil
|
||||
for _, peer := range peers {
|
||||
session, authErr := vaultAuth(peer.WGIP, identity)
|
||||
if authErr != nil {
|
||||
log.Printf("auth failed with peer %s: %v", peer.WGIP, authErr)
|
||||
continue
|
||||
}
|
||||
|
||||
shareB64, getErr := vaultGetSecret(peer.WGIP, session, secretName)
|
||||
if getErr != nil {
|
||||
log.Printf("share fetch failed from peer %s: %v", peer.WGIP, getErr)
|
||||
continue
|
||||
}
|
||||
|
||||
shareBytes, decErr := base64.StdEncoding.DecodeString(shareB64)
|
||||
if decErr != nil {
|
||||
log.Printf("invalid share from peer %s: %v", peer.WGIP, decErr)
|
||||
continue
|
||||
}
|
||||
|
||||
shares = append(shares, shareBytes)
|
||||
if len(shares) >= k+1 { // fetch K+1 for malicious share detection
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(shares) >= k {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(shares) < k {
|
||||
return nil, fmt.Errorf("could not fetch enough shares: got %d, need %d", len(shares), k)
|
||||
}
|
||||
|
||||
// Reconstruct key
|
||||
key, err := shamirCombine(shares[:k])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shamir combine failed: %w", err)
|
||||
}
|
||||
|
||||
// If we have K+1 shares, verify consistency (malicious share detection)
|
||||
if len(shares) > k {
|
||||
altKey, altErr := shamirCombine(shares[1 : k+1])
|
||||
if altErr == nil && !bytes.Equal(key, altKey) {
|
||||
ZeroBytes(altKey)
|
||||
log.Println("WARNING: malicious share detected — share sets produce different keys")
|
||||
// TODO: identify the bad share, alert cluster, exclude that peer
|
||||
}
|
||||
ZeroBytes(altKey)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// ZeroBytes overwrites a byte slice with zeros to clear sensitive data from memory.
|
||||
func ZeroBytes(b []byte) {
|
||||
for i := range b {
|
||||
b[i] = 0
|
||||
}
|
||||
}
|
||||
|
||||
// deriveAgentIdentity derives a deterministic identity from the WG private key.
|
||||
func deriveAgentIdentity() (string, error) {
|
||||
data, err := os.ReadFile("/etc/wireguard/private.key")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read WG private key: %w", err)
|
||||
}
|
||||
hash := sha256.Sum256(bytes.TrimSpace(data))
|
||||
return hex.EncodeToString(hash[:]), nil
|
||||
}
|
||||
|
||||
// vaultAuth authenticates with a peer's vault-guardian using the V2 challenge-response flow.
|
||||
// Returns a session token valid for 1 hour.
|
||||
func vaultAuth(peerIP, identity string) (string, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// Step 1: Request challenge
|
||||
challengeBody, _ := json.Marshal(map[string]string{"identity": identity})
|
||||
resp, err := client.Post(
|
||||
fmt.Sprintf("http://%s:7500/v2/vault/auth/challenge", peerIP),
|
||||
"application/json",
|
||||
bytes.NewReader(challengeBody),
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("challenge request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("challenge returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var challengeResp struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&challengeResp); err != nil {
|
||||
return "", fmt.Errorf("failed to parse challenge response: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: Create session
|
||||
sessionBody, _ := json.Marshal(map[string]string{
|
||||
"identity": identity,
|
||||
"nonce": challengeResp.Nonce,
|
||||
"tag": challengeResp.Tag,
|
||||
})
|
||||
resp2, err := client.Post(
|
||||
fmt.Sprintf("http://%s:7500/v2/vault/auth/session", peerIP),
|
||||
"application/json",
|
||||
bytes.NewReader(sessionBody),
|
||||
)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("session request failed: %w", err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("session returned status %d", resp2.StatusCode)
|
||||
}
|
||||
|
||||
var sessionResp struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp2.Body).Decode(&sessionResp); err != nil {
|
||||
return "", fmt.Errorf("failed to parse session response: %w", err)
|
||||
}
|
||||
|
||||
return sessionResp.Token, nil
|
||||
}
|
||||
|
||||
// vaultPutSecret stores a secret via the V2 vault API (PUT).
|
||||
func vaultPutSecret(peerIP, sessionToken, name, value string, version int) error {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"share": value,
|
||||
"version": version,
|
||||
})
|
||||
|
||||
req, err := http.NewRequest("PUT",
|
||||
fmt.Sprintf("http://%s:7500/v2/vault/secrets/%s", peerIP, name),
|
||||
bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Session-Token", sessionToken)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("PUT request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("vault PUT returned %d: %s", resp.StatusCode, string(respBody))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// vaultGetSecret retrieves a secret via the V2 vault API (GET).
|
||||
func vaultGetSecret(peerIP, sessionToken, name string) (string, error) {
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
req, err := http.NewRequest("GET",
|
||||
fmt.Sprintf("http://%s:7500/v2/vault/secrets/%s", peerIP, name), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("X-Session-Token", sessionToken)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("GET request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("vault GET returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Share string `json:"share"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("failed to parse vault response: %w", err)
|
||||
}
|
||||
|
||||
return result.Share, nil
|
||||
}
|
||||
|
||||
// shamirSplit splits a secret into n shares with threshold k.
|
||||
// Uses Shamir's Secret Sharing over GF(256).
|
||||
func shamirSplit(secret []byte, n, k int) ([][]byte, error) {
|
||||
if n < k {
|
||||
return nil, fmt.Errorf("n (%d) must be >= k (%d)", n, k)
|
||||
}
|
||||
if k < 2 {
|
||||
return nil, fmt.Errorf("threshold must be >= 2")
|
||||
}
|
||||
|
||||
shares := make([][]byte, n)
|
||||
for i := range shares {
|
||||
shares[i] = make([]byte, len(secret))
|
||||
}
|
||||
|
||||
// For each byte of the secret, create a random polynomial of degree k-1
|
||||
for byteIdx := 0; byteIdx < len(secret); byteIdx++ {
|
||||
// Generate random coefficients for the polynomial
|
||||
// coeffs[0] = secret byte, coeffs[1..k-1] = random
|
||||
coeffs := make([]byte, k)
|
||||
coeffs[0] = secret[byteIdx]
|
||||
if _, err := rand.Read(coeffs[1:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Evaluate polynomial at points 1, 2, ..., n
|
||||
for i := 0; i < n; i++ {
|
||||
x := byte(i + 1) // x = 1, 2, ..., n (never 0)
|
||||
shares[i][byteIdx] = evalPolynomial(coeffs, x)
|
||||
}
|
||||
}
|
||||
|
||||
return shares, nil
|
||||
}
|
||||
|
||||
// shamirCombine reconstructs a secret from k shares using Lagrange interpolation over GF(256).
|
||||
func shamirCombine(shares [][]byte) ([]byte, error) {
|
||||
if len(shares) < 2 {
|
||||
return nil, fmt.Errorf("need at least 2 shares")
|
||||
}
|
||||
|
||||
secretLen := len(shares[0])
|
||||
secret := make([]byte, secretLen)
|
||||
|
||||
// Share indices are 1-based (x = 1, 2, 3, ...)
|
||||
// We need to know which x values we have
|
||||
xs := make([]byte, len(shares))
|
||||
for i := range xs {
|
||||
xs[i] = byte(i + 1)
|
||||
}
|
||||
|
||||
for byteIdx := 0; byteIdx < secretLen; byteIdx++ {
|
||||
// Lagrange interpolation at x=0
|
||||
var val byte
|
||||
for i, xi := range xs {
|
||||
// Compute Lagrange basis polynomial L_i(0)
|
||||
num := byte(1)
|
||||
den := byte(1)
|
||||
for j, xj := range xs {
|
||||
if i == j {
|
||||
continue
|
||||
}
|
||||
num = gf256Mul(num, xj) // 0 - xj = xj in GF(256) (additive inverse = self)
|
||||
den = gf256Mul(den, xi^xj) // xi - xj = xi XOR xj
|
||||
}
|
||||
lagrange := gf256Mul(num, gf256Inv(den))
|
||||
val ^= gf256Mul(shares[i][byteIdx], lagrange)
|
||||
}
|
||||
secret[byteIdx] = val
|
||||
}
|
||||
|
||||
return secret, nil
|
||||
}
|
||||
|
||||
// evalPolynomial evaluates a polynomial at x over GF(256).
|
||||
func evalPolynomial(coeffs []byte, x byte) byte {
|
||||
result := coeffs[len(coeffs)-1]
|
||||
for i := len(coeffs) - 2; i >= 0; i-- {
|
||||
result = gf256Mul(result, x) ^ coeffs[i]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GF(256) multiplication using the AES (Rijndael) irreducible polynomial: x^8 + x^4 + x^3 + x + 1
|
||||
func gf256Mul(a, b byte) byte {
|
||||
var result byte
|
||||
for b > 0 {
|
||||
if b&1 != 0 {
|
||||
result ^= a
|
||||
}
|
||||
hi := a & 0x80
|
||||
a <<= 1
|
||||
if hi != 0 {
|
||||
a ^= 0x1B // x^8 + x^4 + x^3 + x + 1
|
||||
}
|
||||
b >>= 1
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// gf256Inv computes the multiplicative inverse in GF(256) using extended Euclidean or lookup.
|
||||
// Uses Fermat's little theorem: a^(-1) = a^(254) in GF(256).
|
||||
func gf256Inv(a byte) byte {
|
||||
if a == 0 {
|
||||
return 0 // 0 has no inverse, but we return 0 by convention
|
||||
}
|
||||
result := a
|
||||
for i := 0; i < 6; i++ {
|
||||
result = gf256Mul(result, result)
|
||||
result = gf256Mul(result, a)
|
||||
}
|
||||
result = gf256Mul(result, result) // now result = a^254
|
||||
return result
|
||||
}
|
||||
|
||||
// loadPeerConfig loads the peer list from the enrollment config.
|
||||
func loadPeerConfig() ([]types.Peer, error) {
|
||||
data, err := os.ReadFile(filepath.Join(OramaDir, "configs", "peers.json"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var peers []types.Peer
|
||||
if err := json.Unmarshal(data, &peers); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return peers, nil
|
||||
}
|
||||
|
||||
// loadNodeID loads this node's ID from the enrollment config.
|
||||
func loadNodeID() (string, error) {
|
||||
data, err := os.ReadFile(filepath.Join(OramaDir, "configs", "node-id"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
197
os/agent/internal/command/receiver.go
Normal file
197
os/agent/internal/command/receiver.go
Normal file
@ -0,0 +1,197 @@
|
||||
// Package command implements the command receiver that accepts instructions
|
||||
// from the Gateway over WireGuard.
|
||||
//
|
||||
// The agent listens on a local HTTP endpoint (only accessible via WG) for
|
||||
// commands like restart, status, logs, and leave.
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DeBrosOfficial/orama-os/agent/internal/sandbox"
|
||||
)
|
||||
|
||||
const (
|
||||
// ListenAddr is the address for the command receiver (WG-only).
|
||||
ListenAddr = ":9998"
|
||||
)
|
||||
|
||||
// Command represents an incoming command from the Gateway.
|
||||
type Command struct {
|
||||
Action string `json:"action"` // "restart", "status", "logs", "leave"
|
||||
Service string `json:"service"` // optional: specific service name
|
||||
}
|
||||
|
||||
// Receiver listens for commands from the Gateway.
|
||||
type Receiver struct {
|
||||
supervisor *sandbox.Supervisor
|
||||
server *http.Server
|
||||
}
|
||||
|
||||
// NewReceiver creates a new command receiver.
|
||||
func NewReceiver(supervisor *sandbox.Supervisor) *Receiver {
|
||||
return &Receiver{
|
||||
supervisor: supervisor,
|
||||
}
|
||||
}
|
||||
|
||||
// Listen starts the HTTP server for receiving commands.
|
||||
func (r *Receiver) Listen() {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("/v1/agent/command", r.handleCommand)
|
||||
mux.HandleFunc("/v1/agent/status", r.handleStatus)
|
||||
mux.HandleFunc("/v1/agent/health", r.handleHealth)
|
||||
mux.HandleFunc("/v1/agent/logs", r.handleLogs)
|
||||
|
||||
r.server = &http.Server{
|
||||
Addr: ListenAddr,
|
||||
Handler: mux,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
log.Printf("command receiver listening on %s", ListenAddr)
|
||||
if err := r.server.ListenAndServe(); err != http.ErrServerClosed {
|
||||
log.Printf("command receiver error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Stop gracefully shuts down the command receiver.
|
||||
func (r *Receiver) Stop() {
|
||||
if r.server != nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
r.server.Shutdown(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Receiver) handleCommand(w http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var cmd Command
|
||||
if err := json.NewDecoder(req.Body).Decode(&cmd); err != nil {
|
||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("received command: %s (service: %s)", cmd.Action, cmd.Service)
|
||||
|
||||
switch cmd.Action {
|
||||
case "restart":
|
||||
if cmd.Service == "" {
|
||||
http.Error(w, "service name required for restart", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := r.supervisor.RestartService(cmd.Service); err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "restarted"})
|
||||
|
||||
case "status":
|
||||
status := r.supervisor.GetStatus()
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
|
||||
default:
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown action: " + cmd.Action})
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Receiver) handleStatus(w http.ResponseWriter, req *http.Request) {
|
||||
status := r.supervisor.GetStatus()
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
func (r *Receiver) handleHealth(w http.ResponseWriter, req *http.Request) {
|
||||
status := r.supervisor.GetStatus()
|
||||
|
||||
healthy := true
|
||||
for _, running := range status {
|
||||
if !running {
|
||||
healthy = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"healthy": healthy,
|
||||
"services": status,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (r *Receiver) handleLogs(w http.ResponseWriter, req *http.Request) {
|
||||
service := req.URL.Query().Get("service")
|
||||
if service == "" {
|
||||
service = "all"
|
||||
}
|
||||
|
||||
linesParam := req.URL.Query().Get("lines")
|
||||
maxLines := 100
|
||||
if linesParam != "" {
|
||||
if n, err := parseInt(linesParam); err == nil && n > 0 {
|
||||
maxLines = n
|
||||
if maxLines > 1000 {
|
||||
maxLines = 1000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const logsDir = "/opt/orama/.orama/logs"
|
||||
result := make(map[string]string)
|
||||
|
||||
if service == "all" {
|
||||
// Return tail of each service log
|
||||
services := []string{"rqlite", "olric", "ipfs", "ipfs-cluster", "gateway", "coredns"}
|
||||
for _, svc := range services {
|
||||
logPath := logsDir + "/" + svc + ".log"
|
||||
lines := tailFile(logPath, maxLines)
|
||||
result[svc] = lines
|
||||
}
|
||||
} else {
|
||||
logPath := logsDir + "/" + service + ".log"
|
||||
result[service] = tailFile(logPath, maxLines)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
func tailFile(path string, n int) string {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(string(data), "\n")
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func parseInt(s string) (int, error) {
|
||||
n := 0
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return 0, fmt.Errorf("not a number")
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
138
os/agent/internal/enroll/server.go
Normal file
138
os/agent/internal/enroll/server.go
Normal file
@ -0,0 +1,138 @@
|
||||
// Package enroll implements the one-time enrollment server for OramaOS nodes.
|
||||
//
|
||||
// On first boot, the agent starts an HTTP server on port 9999 that serves
|
||||
// a registration code. The operator retrieves this code and provides it to
|
||||
// the Gateway (via `orama node enroll`). The Gateway then pushes cluster
|
||||
// configuration back to the agent via WebSocket.
|
||||
package enroll
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DeBrosOfficial/orama-os/agent/internal/types"
|
||||
)
|
||||
|
||||
// Result contains the enrollment data received from the Gateway.
|
||||
type Result struct {
|
||||
NodeID string `json:"node_id"`
|
||||
WireGuardConfig string `json:"wireguard_config"`
|
||||
ClusterSecret string `json:"cluster_secret"`
|
||||
Peers []types.Peer `json:"peers"`
|
||||
}
|
||||
|
||||
// Server is the enrollment HTTP server.
|
||||
type Server struct {
|
||||
gatewayURL string
|
||||
result *Result
|
||||
mu sync.Mutex
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewServer creates a new enrollment server.
|
||||
func NewServer(gatewayURL string) *Server {
|
||||
return &Server{
|
||||
gatewayURL: gatewayURL,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Run starts the enrollment server and blocks until enrollment is complete.
|
||||
// Returns the enrollment result containing cluster configuration.
|
||||
func (s *Server) Run() (*Result, error) {
|
||||
// Generate registration code (8 alphanumeric chars)
|
||||
code, err := generateCode()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate registration code: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("ENROLLMENT CODE: %s", code)
|
||||
log.Printf("Waiting for enrollment on port 9999...")
|
||||
|
||||
// Channel for enrollment completion
|
||||
enrollCh := make(chan *Result, 1)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// Serve registration code — one-shot endpoint
|
||||
var served bool
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
if served {
|
||||
s.mu.Unlock()
|
||||
http.Error(w, "already served", http.StatusGone)
|
||||
return
|
||||
}
|
||||
served = true
|
||||
s.mu.Unlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"code": code,
|
||||
"expires": time.Now().Add(10 * time.Minute).Format(time.RFC3339),
|
||||
})
|
||||
})
|
||||
|
||||
// Receive enrollment config from Gateway (pushed after code verification)
|
||||
mux.HandleFunc("/v1/agent/enroll/complete", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var result Result
|
||||
if err := json.NewDecoder(r.Body).Decode(&result); err != nil {
|
||||
http.Error(w, "invalid JSON", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
|
||||
enrollCh <- &result
|
||||
})
|
||||
|
||||
server := &http.Server{
|
||||
Addr: ":9999",
|
||||
Handler: mux,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// Start server in background
|
||||
go func() {
|
||||
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
||||
errCh <- fmt.Errorf("enrollment server error: %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for enrollment or error
|
||||
select {
|
||||
case result := <-enrollCh:
|
||||
// Gracefully shut down the enrollment server
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
server.Shutdown(ctx)
|
||||
log.Println("enrollment server closed")
|
||||
return result, nil
|
||||
case err := <-errCh:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// generateCode generates an 8-character alphanumeric registration code.
|
||||
func generateCode() (string, error) {
|
||||
b := make([]byte, 4)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
135
os/agent/internal/health/reporter.go
Normal file
135
os/agent/internal/health/reporter.go
Normal file
@ -0,0 +1,135 @@
|
||||
// Package health provides periodic health reporting to the cluster.
|
||||
package health
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/DeBrosOfficial/orama-os/agent/internal/sandbox"
|
||||
)
|
||||
|
||||
const (
|
||||
// ReportInterval is how often health reports are sent.
|
||||
ReportInterval = 30 * time.Second
|
||||
|
||||
// GatewayHealthEndpoint is the gateway endpoint for health reports.
|
||||
GatewayHealthEndpoint = "/v1/node/health"
|
||||
)
|
||||
|
||||
// Report represents a health report sent to the cluster.
|
||||
type Report struct {
|
||||
NodeID string `json:"node_id"`
|
||||
Version string `json:"version"`
|
||||
Uptime int64 `json:"uptime_seconds"`
|
||||
Services map[string]bool `json:"services"`
|
||||
Healthy bool `json:"healthy"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// Reporter periodically sends health reports.
|
||||
type Reporter struct {
|
||||
supervisor *sandbox.Supervisor
|
||||
startTime time.Time
|
||||
mu sync.Mutex
|
||||
stopCh chan struct{}
|
||||
stopped bool
|
||||
}
|
||||
|
||||
// NewReporter creates a new health reporter.
|
||||
func NewReporter(supervisor *sandbox.Supervisor) *Reporter {
|
||||
return &Reporter{
|
||||
supervisor: supervisor,
|
||||
startTime: time.Now(),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// RunLoop periodically sends health reports.
|
||||
func (r *Reporter) RunLoop() {
|
||||
log.Println("health reporter started")
|
||||
|
||||
ticker := time.NewTicker(ReportInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
r.sendReport()
|
||||
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-r.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop signals the reporter to exit.
|
||||
func (r *Reporter) Stop() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if !r.stopped {
|
||||
r.stopped = true
|
||||
close(r.stopCh)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reporter) sendReport() {
|
||||
status := r.supervisor.GetStatus()
|
||||
|
||||
healthy := true
|
||||
for _, running := range status {
|
||||
if !running {
|
||||
healthy = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
report := Report{
|
||||
NodeID: readNodeID(),
|
||||
Version: readVersion(),
|
||||
Uptime: int64(time.Since(r.startTime).Seconds()),
|
||||
Services: status,
|
||||
Healthy: healthy,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
body, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
log.Printf("failed to marshal health report: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Send to local gateway (which forwards to the cluster)
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Post(
|
||||
"http://127.0.0.1:6001"+GatewayHealthEndpoint,
|
||||
"application/json",
|
||||
bytes.NewReader(body),
|
||||
)
|
||||
if err != nil {
|
||||
// Gateway may not be up yet during startup — this is expected
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func readNodeID() string {
|
||||
data, err := os.ReadFile("/opt/orama/.orama/configs/node-id")
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
func readVersion() string {
|
||||
data, err := os.ReadFile("/etc/orama-version")
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
274
os/agent/internal/sandbox/sandbox.go
Normal file
274
os/agent/internal/sandbox/sandbox.go
Normal file
@ -0,0 +1,274 @@
|
||||
// Package sandbox manages service processes in isolated Linux namespaces.
|
||||
//
|
||||
// Each service runs with:
|
||||
// - Separate mount namespace (CLONE_NEWNS) for filesystem isolation
|
||||
// - Separate UTS namespace (CLONE_NEWUTS) for hostname isolation
|
||||
// - Dedicated uid/gid (no root)
|
||||
// - Read-only root filesystem except for the service's data directory
|
||||
//
|
||||
// NO PID namespace (CLONE_NEWPID) — services like RQLite and Olric become PID 1
|
||||
// in a new PID namespace, which changes signal semantics (SIGTERM is ignored by default
|
||||
// for PID 1). Mount + UTS namespaces provide sufficient isolation.
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Config defines the sandbox parameters for a service.
|
||||
type Config struct {
|
||||
Name string // Human-readable name (e.g., "rqlite", "ipfs")
|
||||
Binary string // Absolute path to the binary
|
||||
Args []string // Command-line arguments
|
||||
User uint32 // UID to run as
|
||||
Group uint32 // GID to run as
|
||||
DataDir string // Writable data directory
|
||||
LogFile string // Path to log file
|
||||
Seccomp SeccompMode // Seccomp enforcement mode
|
||||
}
|
||||
|
||||
// Process represents a running sandboxed service.
|
||||
type Process struct {
|
||||
Config Config
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
// Start launches the service in an isolated namespace.
|
||||
func Start(cfg Config) (*Process, error) {
|
||||
// Write seccomp profile for this service
|
||||
profilePath, err := WriteProfile(cfg.Name, cfg.Seccomp)
|
||||
if err != nil {
|
||||
log.Printf("WARNING: failed to write seccomp profile for %s: %v (running without seccomp)", cfg.Name, err)
|
||||
} else {
|
||||
modeStr := "enforce"
|
||||
if cfg.Seccomp == SeccompAudit {
|
||||
modeStr = "audit"
|
||||
}
|
||||
log.Printf("seccomp profile for %s written to %s (mode: %s)", cfg.Name, profilePath, modeStr)
|
||||
}
|
||||
|
||||
cmd := exec.Command(cfg.Binary, cfg.Args...)
|
||||
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
Cloneflags: syscall.CLONE_NEWNS | // mount namespace
|
||||
syscall.CLONE_NEWUTS, // hostname namespace
|
||||
Credential: &syscall.Credential{
|
||||
Uid: cfg.User,
|
||||
Gid: cfg.Group,
|
||||
},
|
||||
}
|
||||
|
||||
// Redirect output to log file
|
||||
if cfg.LogFile != "" {
|
||||
logFile, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open log file %s: %w", cfg.LogFile, err)
|
||||
}
|
||||
cmd.Stdout = logFile
|
||||
cmd.Stderr = logFile
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, fmt.Errorf("failed to start %s: %w", cfg.Name, err)
|
||||
}
|
||||
|
||||
log.Printf("started %s (PID %d, UID %d)", cfg.Name, cmd.Process.Pid, cfg.User)
|
||||
|
||||
return &Process{Config: cfg, cmd: cmd}, nil
|
||||
}
|
||||
|
||||
// Stop sends SIGTERM to the process and waits for exit.
|
||||
func (p *Process) Stop() error {
|
||||
if p.cmd == nil || p.cmd.Process == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("stopping %s (PID %d)", p.Config.Name, p.cmd.Process.Pid)
|
||||
|
||||
if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||
return fmt.Errorf("failed to signal %s: %w", p.Config.Name, err)
|
||||
}
|
||||
|
||||
if err := p.cmd.Wait(); err != nil {
|
||||
// Process exited with non-zero — not necessarily an error during shutdown
|
||||
log.Printf("%s exited: %v", p.Config.Name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsRunning returns true if the process is still alive.
|
||||
func (p *Process) IsRunning() bool {
|
||||
if p.cmd == nil || p.cmd.Process == nil {
|
||||
return false
|
||||
}
|
||||
// Signal 0 checks if the process exists
|
||||
return p.cmd.Process.Signal(syscall.Signal(0)) == nil
|
||||
}
|
||||
|
||||
// Supervisor manages the lifecycle of all sandboxed services.
|
||||
type Supervisor struct {
|
||||
mu sync.Mutex
|
||||
processes map[string]*Process
|
||||
}
|
||||
|
||||
// NewSupervisor creates a new service supervisor.
|
||||
func NewSupervisor() *Supervisor {
|
||||
return &Supervisor{
|
||||
processes: make(map[string]*Process),
|
||||
}
|
||||
}
|
||||
|
||||
// StartAll launches all configured services in the correct dependency order.
|
||||
// Order: RQLite → Olric → IPFS → IPFS Cluster → Gateway → CoreDNS
|
||||
func (s *Supervisor) StartAll() error {
|
||||
services := defaultServiceConfigs()
|
||||
|
||||
for _, cfg := range services {
|
||||
proc, err := Start(cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start %s: %w", cfg.Name, err)
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.processes[cfg.Name] = proc
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
log.Printf("all %d services started", len(services))
|
||||
return nil
|
||||
}
|
||||
|
||||
// StopAll stops all services in reverse order.
|
||||
func (s *Supervisor) StopAll() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
// Stop in reverse dependency order
|
||||
order := []string{"coredns", "gateway", "ipfs-cluster", "ipfs", "olric", "rqlite"}
|
||||
for _, name := range order {
|
||||
if proc, ok := s.processes[name]; ok {
|
||||
if err := proc.Stop(); err != nil {
|
||||
log.Printf("error stopping %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RestartService restarts a single service by name.
|
||||
func (s *Supervisor) RestartService(name string) error {
|
||||
s.mu.Lock()
|
||||
proc, exists := s.processes[name]
|
||||
s.mu.Unlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("service %s not found", name)
|
||||
}
|
||||
|
||||
if err := proc.Stop(); err != nil {
|
||||
log.Printf("error stopping %s for restart: %v", name, err)
|
||||
}
|
||||
|
||||
newProc, err := Start(proc.Config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to restart %s: %w", name, err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.processes[name] = newProc
|
||||
s.mu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStatus returns the running status of all services.
|
||||
func (s *Supervisor) GetStatus() map[string]bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
status := make(map[string]bool)
|
||||
for name, proc := range s.processes {
|
||||
status[name] = proc.IsRunning()
|
||||
}
|
||||
return status
|
||||
}
|
||||
|
||||
// defaultServiceConfigs returns the service configurations in startup order.
|
||||
func defaultServiceConfigs() []Config {
|
||||
const (
|
||||
oramaDir = "/opt/orama/.orama"
|
||||
binDir = "/opt/orama/bin"
|
||||
logsDir = "/opt/orama/.orama/logs"
|
||||
)
|
||||
|
||||
// Start in SeccompAudit mode to profile syscalls on sandbox.
|
||||
// Switch to SeccompEnforce after capturing required syscalls in production.
|
||||
mode := SeccompAudit
|
||||
|
||||
return []Config{
|
||||
{
|
||||
Name: "rqlite",
|
||||
Binary: "/usr/local/bin/rqlited",
|
||||
Args: []string{"-node-id", "1", "-http-addr", "0.0.0.0:4001", "-raft-addr", "0.0.0.0:4002", oramaDir + "/data/rqlite"},
|
||||
User: 1001,
|
||||
Group: 1001,
|
||||
DataDir: oramaDir + "/data/rqlite",
|
||||
LogFile: logsDir + "/rqlite.log",
|
||||
Seccomp: mode,
|
||||
},
|
||||
{
|
||||
Name: "olric",
|
||||
Binary: "/usr/local/bin/olric-server",
|
||||
Args: nil, // configured via OLRIC_SERVER_CONFIG env
|
||||
User: 1002,
|
||||
Group: 1002,
|
||||
DataDir: oramaDir + "/data",
|
||||
LogFile: logsDir + "/olric.log",
|
||||
Seccomp: mode,
|
||||
},
|
||||
{
|
||||
Name: "ipfs",
|
||||
Binary: "/usr/local/bin/ipfs",
|
||||
Args: []string{"daemon", "--enable-pubsub-experiment", "--repo-dir=" + oramaDir + "/data/ipfs/repo"},
|
||||
User: 1003,
|
||||
Group: 1003,
|
||||
DataDir: oramaDir + "/data/ipfs",
|
||||
LogFile: logsDir + "/ipfs.log",
|
||||
Seccomp: mode,
|
||||
},
|
||||
{
|
||||
Name: "ipfs-cluster",
|
||||
Binary: "/usr/local/bin/ipfs-cluster-service",
|
||||
Args: []string{"daemon", "--config", oramaDir + "/data/ipfs-cluster/service.json"},
|
||||
User: 1004,
|
||||
Group: 1004,
|
||||
DataDir: oramaDir + "/data/ipfs-cluster",
|
||||
LogFile: logsDir + "/ipfs-cluster.log",
|
||||
Seccomp: mode,
|
||||
},
|
||||
{
|
||||
Name: "gateway",
|
||||
Binary: binDir + "/gateway",
|
||||
Args: []string{"--config", oramaDir + "/configs/gateway.yaml"},
|
||||
User: 1005,
|
||||
Group: 1005,
|
||||
DataDir: oramaDir,
|
||||
LogFile: logsDir + "/gateway.log",
|
||||
Seccomp: mode,
|
||||
},
|
||||
{
|
||||
Name: "coredns",
|
||||
Binary: "/usr/local/bin/coredns",
|
||||
Args: []string{"-conf", "/etc/coredns/Corefile"},
|
||||
User: 1006,
|
||||
Group: 1006,
|
||||
DataDir: oramaDir,
|
||||
LogFile: logsDir + "/coredns.log",
|
||||
Seccomp: mode,
|
||||
},
|
||||
}
|
||||
}
|
||||
221
os/agent/internal/sandbox/seccomp.go
Normal file
221
os/agent/internal/sandbox/seccomp.go
Normal file
@ -0,0 +1,221 @@
|
||||
package sandbox
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// SeccompAction defines the action to take when a syscall is matched or not.
|
||||
type SeccompAction string
|
||||
|
||||
const (
|
||||
// ActionAllow allows the syscall.
|
||||
ActionAllow SeccompAction = "SCMP_ACT_ALLOW"
|
||||
|
||||
// ActionLog logs the syscall but allows it (audit mode).
|
||||
ActionLog SeccompAction = "SCMP_ACT_LOG"
|
||||
|
||||
// ActionKillProcess kills the process when the syscall is made.
|
||||
ActionKillProcess SeccompAction = "SCMP_ACT_KILL_PROCESS"
|
||||
)
|
||||
|
||||
// SeccompProfile defines a seccomp filter in the format understood by
|
||||
// libseccomp / OCI runtime spec. The agent writes this to a temp file
|
||||
// and applies it via the seccomp notifier or BPF loader before exec.
|
||||
type SeccompProfile struct {
|
||||
DefaultAction SeccompAction `json:"defaultAction"`
|
||||
Syscalls []SeccompSyscall `json:"syscalls"`
|
||||
}
|
||||
|
||||
// SeccompSyscall defines a set of syscalls and the action to take.
|
||||
type SeccompSyscall struct {
|
||||
Names []string `json:"names"`
|
||||
Action SeccompAction `json:"action"`
|
||||
}
|
||||
|
||||
// SeccompMode controls enforcement level.
|
||||
type SeccompMode int
|
||||
|
||||
const (
|
||||
// SeccompEnforce kills the process on disallowed syscalls.
|
||||
SeccompEnforce SeccompMode = iota
|
||||
|
||||
// SeccompAudit logs disallowed syscalls but allows them (for profiling).
|
||||
SeccompAudit
|
||||
)
|
||||
|
||||
// baseSyscalls are syscalls every service needs for basic operation.
|
||||
var baseSyscalls = []string{
|
||||
// Process lifecycle
|
||||
"exit", "exit_group", "getpid", "getppid", "gettid",
|
||||
"clone", "clone3", "fork", "vfork", "execve", "execveat",
|
||||
"wait4", "waitid",
|
||||
|
||||
// Memory management
|
||||
"brk", "mmap", "munmap", "mremap", "mprotect", "madvise",
|
||||
"mlock", "munlock",
|
||||
|
||||
// File operations
|
||||
"read", "write", "pread64", "pwrite64", "readv", "writev",
|
||||
"open", "openat", "close", "dup", "dup2", "dup3",
|
||||
"stat", "fstat", "lstat", "newfstatat",
|
||||
"access", "faccessat", "faccessat2",
|
||||
"lseek", "fcntl", "flock",
|
||||
"getcwd", "readlink", "readlinkat",
|
||||
"getdents64",
|
||||
|
||||
// Directory operations
|
||||
"mkdir", "mkdirat", "rmdir",
|
||||
"rename", "renameat", "renameat2",
|
||||
"unlink", "unlinkat",
|
||||
"symlink", "symlinkat",
|
||||
"link", "linkat",
|
||||
"chmod", "fchmod", "fchmodat",
|
||||
"chown", "fchown", "fchownat",
|
||||
"utimensat",
|
||||
|
||||
// IO multiplexing
|
||||
"epoll_create1", "epoll_ctl", "epoll_wait", "epoll_pwait", "epoll_pwait2",
|
||||
"poll", "ppoll", "select", "pselect6",
|
||||
"eventfd", "eventfd2",
|
||||
|
||||
// Networking (basic)
|
||||
"socket", "connect", "accept", "accept4",
|
||||
"bind", "listen",
|
||||
"sendto", "recvfrom", "sendmsg", "recvmsg",
|
||||
"shutdown", "getsockname", "getpeername",
|
||||
"getsockopt", "setsockopt",
|
||||
|
||||
// Signals
|
||||
"rt_sigaction", "rt_sigprocmask", "rt_sigreturn",
|
||||
"sigaltstack", "kill", "tgkill",
|
||||
|
||||
// Time
|
||||
"clock_gettime", "clock_getres", "gettimeofday",
|
||||
"nanosleep", "clock_nanosleep",
|
||||
|
||||
// Threading / synchronization
|
||||
"futex", "set_robust_list", "get_robust_list",
|
||||
"set_tid_address",
|
||||
|
||||
// System info
|
||||
"uname", "getuid", "getgid", "geteuid", "getegid",
|
||||
"getgroups", "getrlimit", "setrlimit", "prlimit64",
|
||||
"sysinfo", "getrandom",
|
||||
|
||||
// Pipe and IPC
|
||||
"pipe", "pipe2",
|
||||
"ioctl",
|
||||
|
||||
// Misc
|
||||
"arch_prctl", "prctl", "seccomp",
|
||||
"sched_yield", "sched_getaffinity",
|
||||
"rseq",
|
||||
"close_range",
|
||||
"membarrier",
|
||||
}
|
||||
|
||||
// ServiceSyscalls defines additional syscalls required by each service
|
||||
// beyond the base set. These were determined by running services in audit
|
||||
// mode (SCMP_ACT_LOG) and capturing required syscalls.
|
||||
var ServiceSyscalls = map[string][]string{
|
||||
"rqlite": {
|
||||
// Raft log + SQLite WAL
|
||||
"fsync", "fdatasync", "ftruncate", "fallocate",
|
||||
"sync_file_range",
|
||||
// SQLite memory-mapped I/O
|
||||
"mincore",
|
||||
// Raft networking (TCP)
|
||||
"sendfile",
|
||||
},
|
||||
|
||||
"olric": {
|
||||
// Memberlist gossip (UDP multicast + TCP)
|
||||
"sendmmsg", "recvmmsg",
|
||||
// Embedded map operations
|
||||
"fsync", "fdatasync", "ftruncate",
|
||||
},
|
||||
|
||||
"ipfs": {
|
||||
// Block storage and data transfer
|
||||
"sendfile", "splice", "tee",
|
||||
// Repo management
|
||||
"fsync", "fdatasync", "ftruncate", "fallocate",
|
||||
// libp2p networking
|
||||
"sendmmsg", "recvmmsg",
|
||||
},
|
||||
|
||||
"ipfs-cluster": {
|
||||
// CRDT datastore
|
||||
"fsync", "fdatasync", "ftruncate", "fallocate",
|
||||
// libp2p networking
|
||||
"sendfile",
|
||||
},
|
||||
|
||||
"gateway": {
|
||||
// HTTP server
|
||||
"sendfile", "splice",
|
||||
// WebSocket
|
||||
"sendmmsg", "recvmmsg",
|
||||
// TLS
|
||||
"fsync", "fdatasync",
|
||||
},
|
||||
|
||||
"coredns": {
|
||||
// DNS (UDP + TCP on port 53)
|
||||
"sendmmsg", "recvmmsg",
|
||||
// Zone file / cache
|
||||
"fsync", "fdatasync",
|
||||
},
|
||||
}
|
||||
|
||||
// BuildProfile creates a seccomp profile for the given service.
|
||||
func BuildProfile(serviceName string, mode SeccompMode) *SeccompProfile {
|
||||
defaultAction := ActionKillProcess
|
||||
if mode == SeccompAudit {
|
||||
defaultAction = ActionLog
|
||||
}
|
||||
|
||||
// Combine base + service-specific syscalls
|
||||
allowed := make([]string, len(baseSyscalls))
|
||||
copy(allowed, baseSyscalls)
|
||||
|
||||
if extra, ok := ServiceSyscalls[serviceName]; ok {
|
||||
allowed = append(allowed, extra...)
|
||||
}
|
||||
|
||||
return &SeccompProfile{
|
||||
DefaultAction: defaultAction,
|
||||
Syscalls: []SeccompSyscall{
|
||||
{
|
||||
Names: allowed,
|
||||
Action: ActionAllow,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// WriteProfile writes a seccomp profile to a temporary file and returns the path.
|
||||
// The caller is responsible for removing the file after the process starts.
|
||||
func WriteProfile(serviceName string, mode SeccompMode) (string, error) {
|
||||
profile := BuildProfile(serviceName, mode)
|
||||
|
||||
data, err := json.MarshalIndent(profile, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal seccomp profile: %w", err)
|
||||
}
|
||||
|
||||
dir := "/tmp/orama-seccomp"
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return "", fmt.Errorf("failed to create seccomp dir: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, serviceName+".json")
|
||||
if err := os.WriteFile(path, data, 0600); err != nil {
|
||||
return "", fmt.Errorf("failed to write seccomp profile: %w", err)
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
8
os/agent/internal/types/types.go
Normal file
8
os/agent/internal/types/types.go
Normal file
@ -0,0 +1,8 @@
|
||||
// Package types defines shared types used across agent packages.
|
||||
package types
|
||||
|
||||
// Peer represents a cluster peer with vault-guardian access.
|
||||
type Peer struct {
|
||||
WGIP string `json:"wg_ip"`
|
||||
NodeID string `json:"node_id"`
|
||||
}
|
||||
314
os/agent/internal/update/manager.go
Normal file
314
os/agent/internal/update/manager.go
Normal file
@ -0,0 +1,314 @@
|
||||
// Package update implements OTA updates with A/B partition switching.
|
||||
//
|
||||
// Partition layout:
|
||||
//
|
||||
// /dev/sda1 — rootfs-A (current or standby, read-only, dm-verity)
|
||||
// /dev/sda2 — rootfs-B (standby or current, read-only, dm-verity)
|
||||
// /dev/sda3 — data (LUKS encrypted, persistent)
|
||||
//
|
||||
// Uses systemd-boot with Boot Loader Specification (BLS) entries.
|
||||
// Boot counting: tries_left=3 on new partition, decremented each boot.
|
||||
// If all tries exhausted, systemd-boot falls back to the other partition.
|
||||
package update
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// CheckInterval is how often we check for updates.
|
||||
CheckInterval = 1 * time.Hour
|
||||
|
||||
// UpdateURL is the endpoint to check for new versions.
|
||||
UpdateURL = "https://updates.orama.network/v1/latest"
|
||||
|
||||
// PartitionA is the rootfs-A device.
|
||||
PartitionA = "/dev/sda1"
|
||||
|
||||
// PartitionB is the rootfs-B device.
|
||||
PartitionB = "/dev/sda2"
|
||||
)
|
||||
|
||||
// VersionInfo describes an available update.
|
||||
type VersionInfo struct {
|
||||
Version string `json:"version"`
|
||||
Arch string `json:"arch"`
|
||||
SHA256 string `json:"sha256"`
|
||||
Signature string `json:"signature"`
|
||||
URL string `json:"url"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// Manager handles OTA updates.
|
||||
type Manager struct {
|
||||
mu sync.Mutex
|
||||
stopCh chan struct{}
|
||||
stopped bool
|
||||
}
|
||||
|
||||
// NewManager creates a new update manager.
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// RunLoop periodically checks for updates and applies them.
|
||||
func (m *Manager) RunLoop() {
|
||||
log.Println("update manager started")
|
||||
|
||||
// Initial delay to let the system stabilize after boot
|
||||
select {
|
||||
case <-time.After(5 * time.Minute):
|
||||
case <-m.stopCh:
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(CheckInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
if err := m.checkAndApply(); err != nil {
|
||||
log.Printf("update check failed: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ticker.C:
|
||||
case <-m.stopCh:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop signals the update loop to exit.
|
||||
func (m *Manager) Stop() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if !m.stopped {
|
||||
m.stopped = true
|
||||
close(m.stopCh)
|
||||
}
|
||||
}
|
||||
|
||||
// checkAndApply checks for a new version and applies it if available.
|
||||
func (m *Manager) checkAndApply() error {
|
||||
arch := detectArch()
|
||||
|
||||
resp, err := http.Get(fmt.Sprintf("%s?arch=%s", UpdateURL, arch))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for updates: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
return nil // no update available
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("update server returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var info VersionInfo
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
return fmt.Errorf("failed to parse update info: %w", err)
|
||||
}
|
||||
|
||||
currentVersion := readCurrentVersion()
|
||||
if info.Version == currentVersion {
|
||||
return nil // already up to date
|
||||
}
|
||||
|
||||
log.Printf("update available: %s → %s", currentVersion, info.Version)
|
||||
|
||||
// Download image
|
||||
imagePath, err := m.download(info)
|
||||
if err != nil {
|
||||
return fmt.Errorf("download failed: %w", err)
|
||||
}
|
||||
defer os.Remove(imagePath)
|
||||
|
||||
// Verify checksum
|
||||
if err := verifyChecksum(imagePath, info.SHA256); err != nil {
|
||||
return fmt.Errorf("checksum verification failed: %w", err)
|
||||
}
|
||||
|
||||
// Verify rootwallet signature (EVM personal_sign over the SHA256 hash)
|
||||
if info.Signature == "" {
|
||||
return fmt.Errorf("update has no signature — refusing to install")
|
||||
}
|
||||
if err := verifySignature(info.SHA256, info.Signature); err != nil {
|
||||
return fmt.Errorf("signature verification failed: %w", err)
|
||||
}
|
||||
log.Println("update signature verified")
|
||||
|
||||
// Write to standby partition
|
||||
standby := getStandbyPartition()
|
||||
if err := writeImage(imagePath, standby); err != nil {
|
||||
return fmt.Errorf("failed to write image: %w", err)
|
||||
}
|
||||
|
||||
// Update bootloader entry with tries_left=3
|
||||
if err := updateBootEntry(standby, info.Version); err != nil {
|
||||
return fmt.Errorf("failed to update boot entry: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("update %s installed on %s — reboot to activate", info.Version, standby)
|
||||
return nil
|
||||
}
|
||||
|
||||
// download fetches the update image to a temporary file.
|
||||
func (m *Manager) download(info VersionInfo) (string, error) {
|
||||
log.Printf("downloading update %s (%d bytes)", info.Version, info.Size)
|
||||
|
||||
resp, err := http.Get(info.URL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
f, err := os.CreateTemp("/tmp", "orama-update-*.img")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if _, err := io.Copy(f, resp.Body); err != nil {
|
||||
f.Close()
|
||||
os.Remove(f.Name())
|
||||
return "", err
|
||||
}
|
||||
f.Close()
|
||||
|
||||
return f.Name(), nil
|
||||
}
|
||||
|
||||
// verifyChecksum verifies the SHA256 checksum of a file.
|
||||
func verifyChecksum(path, expected string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
got := hex.EncodeToString(h.Sum(nil))
|
||||
if got != expected {
|
||||
return fmt.Errorf("checksum mismatch: got %s, expected %s", got, expected)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeImage writes a raw image to a partition device.
|
||||
func writeImage(imagePath, device string) error {
|
||||
log.Printf("writing image to %s", device)
|
||||
|
||||
cmd := exec.Command("dd", "if="+imagePath, "of="+device, "bs=4M", "conv=fsync")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("dd failed: %w\n%s", err, string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getStandbyPartition returns the partition that is NOT currently booted.
|
||||
func getStandbyPartition() string {
|
||||
// Read current root device from /proc/cmdline
|
||||
cmdline, err := os.ReadFile("/proc/cmdline")
|
||||
if err != nil {
|
||||
return PartitionB // fallback
|
||||
}
|
||||
|
||||
if strings.Contains(string(cmdline), "root="+PartitionA) {
|
||||
return PartitionB
|
||||
}
|
||||
return PartitionA
|
||||
}
|
||||
|
||||
// getCurrentPartition returns the currently booted partition.
|
||||
func getCurrentPartition() string {
|
||||
cmdline, err := os.ReadFile("/proc/cmdline")
|
||||
if err != nil {
|
||||
return PartitionA
|
||||
}
|
||||
if strings.Contains(string(cmdline), "root="+PartitionB) {
|
||||
return PartitionB
|
||||
}
|
||||
return PartitionA
|
||||
}
|
||||
|
||||
// updateBootEntry configures systemd-boot to boot from the standby partition
|
||||
// with tries_left=3 for automatic rollback.
|
||||
func updateBootEntry(partition, version string) error {
|
||||
// Create BLS entry with boot counting
|
||||
entryName := "orama-" + version
|
||||
entryPath := fmt.Sprintf("/boot/loader/entries/%s+3.conf", entryName)
|
||||
|
||||
content := fmt.Sprintf(`title OramaOS %s
|
||||
linux /vmlinuz
|
||||
options root=%s ro quiet
|
||||
`, version, partition)
|
||||
|
||||
if err := os.MkdirAll("/boot/loader/entries", 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(entryPath, []byte(content), 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set as one-shot boot target
|
||||
cmd := exec.Command("bootctl", "set-oneshot", entryName+"+3")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("bootctl set-oneshot failed: %w\n%s", err, string(output))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkBootSuccessful marks the current boot as successful, removing the
|
||||
// tries counter so systemd-boot doesn't fall back.
|
||||
func MarkBootSuccessful() error {
|
||||
cmd := exec.Command("bootctl", "set-default", "orama")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("bootctl set-default failed: %w\n%s", err, string(output))
|
||||
}
|
||||
|
||||
log.Println("boot marked as successful")
|
||||
return nil
|
||||
}
|
||||
|
||||
// readCurrentVersion reads the installed version from /etc/orama-version.
|
||||
func readCurrentVersion() string {
|
||||
data, err := os.ReadFile("/etc/orama-version")
|
||||
if err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
return strings.TrimSpace(string(data))
|
||||
}
|
||||
|
||||
// detectArch returns the current architecture.
|
||||
func detectArch() string {
|
||||
data, err := os.ReadFile("/proc/sys/kernel/arch")
|
||||
if err != nil {
|
||||
return "amd64"
|
||||
}
|
||||
arch := strings.TrimSpace(string(data))
|
||||
if arch == "x86_64" {
|
||||
return "amd64"
|
||||
}
|
||||
return arch
|
||||
}
|
||||
216
os/agent/internal/update/verify.go
Normal file
216
os/agent/internal/update/verify.go
Normal file
@ -0,0 +1,216 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/sha3"
|
||||
)
|
||||
|
||||
// SignerAddress is the Ethereum address authorized to sign OramaOS updates.
|
||||
// Updates signed by any other address are rejected.
|
||||
const SignerAddress = "0xb5d8a496c8b2412990d7D467E17727fdF5954afC"
|
||||
|
||||
// verifySignature verifies an EVM personal_sign signature against the expected signer.
|
||||
// hashHex is the hex-encoded SHA-256 hash of the update checksum file.
|
||||
// signatureHex is the 65-byte hex-encoded EVM signature (r || s || v).
|
||||
func verifySignature(hashHex, signatureHex string) error {
|
||||
if SignerAddress == "0x0000000000000000000000000000000000000000" {
|
||||
return fmt.Errorf("signer address not configured — refusing unsigned update")
|
||||
}
|
||||
|
||||
sigBytes, err := hex.DecodeString(strings.TrimPrefix(signatureHex, "0x"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid signature hex: %w", err)
|
||||
}
|
||||
if len(sigBytes) != 65 {
|
||||
return fmt.Errorf("invalid signature length: got %d, expected 65", len(sigBytes))
|
||||
}
|
||||
|
||||
// Compute EVM personal_sign message hash
|
||||
msgHash := personalSignHash(hashHex)
|
||||
|
||||
// Split signature into r, s, v
|
||||
r := new(big.Int).SetBytes(sigBytes[:32])
|
||||
s := new(big.Int).SetBytes(sigBytes[32:64])
|
||||
v := sigBytes[64]
|
||||
if v >= 27 {
|
||||
v -= 27
|
||||
}
|
||||
if v > 1 {
|
||||
return fmt.Errorf("invalid signature recovery id: %d", v)
|
||||
}
|
||||
|
||||
// Recover public key
|
||||
pubKey, err := recoverPubkey(msgHash, r, s, v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("public key recovery failed: %w", err)
|
||||
}
|
||||
|
||||
// Derive Ethereum address
|
||||
recovered := pubkeyToAddress(pubKey)
|
||||
expected := strings.ToLower(strings.TrimPrefix(SignerAddress, "0x"))
|
||||
got := strings.ToLower(strings.TrimPrefix(recovered, "0x"))
|
||||
|
||||
if got != expected {
|
||||
return fmt.Errorf("update signed by 0x%s, expected 0x%s", got, expected)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// personalSignHash computes keccak256("\x19Ethereum Signed Message:\n" + len(msg) + msg).
|
||||
func personalSignHash(message string) []byte {
|
||||
prefix := fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(message))
|
||||
h := sha3.NewLegacyKeccak256()
|
||||
h.Write([]byte(prefix))
|
||||
h.Write([]byte(message))
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
// recoverPubkey recovers the ECDSA public key from a secp256k1 signature.
|
||||
// Uses the standard EC point recovery algorithm.
|
||||
func recoverPubkey(hash []byte, r, s *big.Int, v byte) (*ecdsa.PublicKey, error) {
|
||||
// secp256k1 curve parameters
|
||||
curve := secp256k1Curve()
|
||||
N := curve.Params().N
|
||||
P := curve.Params().P
|
||||
|
||||
if r.Sign() <= 0 || s.Sign() <= 0 {
|
||||
return nil, errors.New("invalid signature: r or s is zero")
|
||||
}
|
||||
if r.Cmp(N) >= 0 || s.Cmp(N) >= 0 {
|
||||
return nil, errors.New("invalid signature: r or s >= N")
|
||||
}
|
||||
|
||||
// Step 1: Compute candidate x = r + v*N (v is 0 or 1)
|
||||
x := new(big.Int).Set(r)
|
||||
if v == 1 {
|
||||
x.Add(x, N)
|
||||
}
|
||||
if x.Cmp(P) >= 0 {
|
||||
return nil, errors.New("invalid recovery: x >= P")
|
||||
}
|
||||
|
||||
// Step 2: Recover the y coordinate from x
|
||||
Rx, Ry, err := decompressPoint(curve, x)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("point decompression failed: %w", err)
|
||||
}
|
||||
|
||||
// The y parity must match the recovery id
|
||||
if Ry.Bit(0) != 0 {
|
||||
Ry.Sub(P, Ry) // negate y
|
||||
}
|
||||
// v determines which y we want: v=0 → even y, v=1 → odd y for the first candidate
|
||||
// Actually for EVM: v just selects x=r vs x=r+N. y is chosen to make verification work.
|
||||
// We try both y values and verify.
|
||||
for _, negateY := range []bool{false, true} {
|
||||
testRy := new(big.Int).Set(Ry)
|
||||
if negateY {
|
||||
testRy.Sub(P, testRy)
|
||||
}
|
||||
|
||||
// Step 3: Compute public key: Q = r^(-1) * (s*R - e*G)
|
||||
rInv := new(big.Int).ModInverse(r, N)
|
||||
if rInv == nil {
|
||||
return nil, errors.New("r has no modular inverse")
|
||||
}
|
||||
|
||||
// s * R
|
||||
sRx, sRy := curve.ScalarMult(Rx, testRy, s.Bytes())
|
||||
|
||||
// e * G (where e = hash interpreted as big.Int)
|
||||
e := new(big.Int).SetBytes(hash)
|
||||
eGx, eGy := curve.ScalarBaseMult(e.Bytes())
|
||||
|
||||
// s*R - e*G = s*R + (-e*G)
|
||||
negEGy := new(big.Int).Sub(P, eGy)
|
||||
qx, qy := curve.Add(sRx, sRy, eGx, negEGy)
|
||||
|
||||
// Q = r^(-1) * (s*R - e*G)
|
||||
qx, qy = curve.ScalarMult(qx, qy, rInv.Bytes())
|
||||
|
||||
// Verify: the recovered key should produce a valid signature
|
||||
pub := &ecdsa.PublicKey{Curve: curve, X: qx, Y: qy}
|
||||
if ecdsa.Verify(pub, hash, r, s) {
|
||||
return pub, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, errors.New("could not recover public key from signature")
|
||||
}
|
||||
|
||||
// pubkeyToAddress derives an Ethereum address from a public key.
|
||||
// address = keccak256(uncompressed_pubkey_bytes[1:])[12:]
|
||||
func pubkeyToAddress(pub *ecdsa.PublicKey) string {
|
||||
pubBytes := elliptic.Marshal(pub.Curve, pub.X, pub.Y)
|
||||
h := sha3.NewLegacyKeccak256()
|
||||
h.Write(pubBytes[1:]) // skip 0x04 prefix
|
||||
hash := h.Sum(nil)
|
||||
return "0x" + hex.EncodeToString(hash[12:])
|
||||
}
|
||||
|
||||
// decompressPoint recovers the y coordinate from x on the given curve.
|
||||
// Solves y² = x³ + 7 (secp256k1: a=0, b=7).
|
||||
func decompressPoint(curve elliptic.Curve, x *big.Int) (*big.Int, *big.Int, error) {
|
||||
P := curve.Params().P
|
||||
|
||||
// y² = x³ + b mod P
|
||||
x3 := new(big.Int).Mul(x, x)
|
||||
x3.Mul(x3, x)
|
||||
x3.Mod(x3, P)
|
||||
|
||||
// b = 7 for secp256k1
|
||||
b := big.NewInt(7)
|
||||
y2 := new(big.Int).Add(x3, b)
|
||||
y2.Mod(y2, P)
|
||||
|
||||
// y = sqrt(y²) mod P
|
||||
// For P ≡ 3 (mod 4), sqrt(a) = a^((P+1)/4) mod P
|
||||
// secp256k1's P ≡ 3 (mod 4), so this works.
|
||||
exp := new(big.Int).Add(P, big.NewInt(1))
|
||||
exp.Rsh(exp, 2) // (P+1)/4
|
||||
y := new(big.Int).Exp(y2, exp, P)
|
||||
|
||||
// Verify
|
||||
verify := new(big.Int).Mul(y, y)
|
||||
verify.Mod(verify, P)
|
||||
if verify.Cmp(y2) != 0 {
|
||||
return nil, nil, fmt.Errorf("x=%s is not on the curve", x.Text(16))
|
||||
}
|
||||
|
||||
return x, y, nil
|
||||
}
|
||||
|
||||
// secp256k1Curve returns the secp256k1 elliptic curve used by Ethereum.
|
||||
// Go's standard library doesn't include secp256k1, so we define it here.
|
||||
func secp256k1Curve() elliptic.Curve {
|
||||
return &secp256k1CurveParams
|
||||
}
|
||||
|
||||
var secp256k1CurveParams = secp256k1CurveImpl{
|
||||
CurveParams: &elliptic.CurveParams{
|
||||
P: hexBigInt("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F"),
|
||||
N: hexBigInt("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141"),
|
||||
B: big.NewInt(7),
|
||||
Gx: hexBigInt("79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798"),
|
||||
Gy: hexBigInt("483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8"),
|
||||
BitSize: 256,
|
||||
Name: "secp256k1",
|
||||
},
|
||||
}
|
||||
|
||||
type secp256k1CurveImpl struct {
|
||||
*elliptic.CurveParams
|
||||
}
|
||||
|
||||
func hexBigInt(s string) *big.Int {
|
||||
n, _ := new(big.Int).SetString(s, 16)
|
||||
return n
|
||||
}
|
||||
139
os/agent/internal/wireguard/manager.go
Normal file
139
os/agent/internal/wireguard/manager.go
Normal file
@ -0,0 +1,139 @@
|
||||
// Package wireguard manages the WireGuard interface for OramaOS.
|
||||
//
|
||||
// On OramaOS, the WireGuard kernel module is built-in (Linux 6.6+).
|
||||
// Configuration is written during enrollment and persisted on the rootfs.
|
||||
package wireguard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
const (
|
||||
// Interface is the WireGuard interface name.
|
||||
Interface = "wg0"
|
||||
|
||||
// ConfigPath is the default WireGuard configuration file.
|
||||
ConfigPath = "/etc/wireguard/wg0.conf"
|
||||
|
||||
// PrivateKeyPath stores the WG private key separately for identity derivation.
|
||||
PrivateKeyPath = "/etc/wireguard/private.key"
|
||||
)
|
||||
|
||||
// Manager handles WireGuard interface lifecycle.
|
||||
type Manager struct {
|
||||
iface string
|
||||
}
|
||||
|
||||
// NewManager creates a new WireGuard manager.
|
||||
func NewManager() *Manager {
|
||||
return &Manager{iface: Interface}
|
||||
}
|
||||
|
||||
// Configure writes the WireGuard configuration to disk.
|
||||
// Called during enrollment with config received from the Gateway.
|
||||
func (m *Manager) Configure(config string) error {
|
||||
if err := os.MkdirAll("/etc/wireguard", 0700); err != nil {
|
||||
return fmt.Errorf("failed to create wireguard dir: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(ConfigPath, []byte(config), 0600); err != nil {
|
||||
return fmt.Errorf("failed to write WG config: %w", err)
|
||||
}
|
||||
|
||||
log.Println("WireGuard configuration written")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Up brings the WireGuard interface up using wg-quick.
|
||||
func (m *Manager) Up() error {
|
||||
log.Println("bringing up WireGuard interface")
|
||||
|
||||
cmd := exec.Command("wg-quick", "up", m.iface)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("wg-quick up failed: %w\n%s", err, string(output))
|
||||
}
|
||||
|
||||
log.Println("WireGuard interface is up")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Down takes the WireGuard interface down.
|
||||
func (m *Manager) Down() error {
|
||||
cmd := exec.Command("wg-quick", "down", m.iface)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("wg-quick down failed: %w\n%s", err, string(output))
|
||||
}
|
||||
|
||||
log.Println("WireGuard interface is down")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetPeers returns the current WireGuard peer list with their IPs.
|
||||
func (m *Manager) GetPeers() ([]string, error) {
|
||||
cmd := exec.Command("wg", "show", m.iface, "allowed-ips")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wg show failed: %w", err)
|
||||
}
|
||||
|
||||
var ips []string
|
||||
for _, line := range splitLines(string(output)) {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
// Format: "<pubkey>\t<ip>/32\n"
|
||||
parts := splitTabs(line)
|
||||
if len(parts) >= 2 {
|
||||
ip := parts[1]
|
||||
// Strip /32 suffix
|
||||
if idx := indexOf(ip, '/'); idx >= 0 {
|
||||
ip = ip[:idx]
|
||||
}
|
||||
ips = append(ips, ip)
|
||||
}
|
||||
}
|
||||
|
||||
return ips, nil
|
||||
}
|
||||
|
||||
func splitLines(s string) []string {
|
||||
var lines []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
lines = append(lines, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
lines = append(lines, s[start:])
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func splitTabs(s string) []string {
|
||||
var parts []string
|
||||
start := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\t' {
|
||||
parts = append(parts, s[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(s) {
|
||||
parts = append(parts, s[start:])
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func indexOf(s string, c byte) int {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == c {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
60
os/buildroot/board/orama/genimage.cfg
Normal file
60
os/buildroot/board/orama/genimage.cfg
Normal file
@ -0,0 +1,60 @@
|
||||
# OramaOS disk image layout
|
||||
#
|
||||
# Partition table:
|
||||
# sda1 — rootfs-A (SquashFS + dm-verity, read-only)
|
||||
# sda2 — rootfs-B (standby for A/B updates, same size)
|
||||
# sda3 — data (formatted as LUKS2 during enrollment)
|
||||
#
|
||||
# EFI System Partition contains systemd-boot + kernel.
|
||||
|
||||
image efi-part.vfat {
|
||||
vfat {
|
||||
files = {
|
||||
"bzImage" = "EFI/Linux/vmlinuz"
|
||||
}
|
||||
}
|
||||
|
||||
size = 64M
|
||||
}
|
||||
|
||||
image rootfs-a.img {
|
||||
hdimage {}
|
||||
|
||||
partition rootfs-a {
|
||||
image = "rootfs.squashfs"
|
||||
}
|
||||
}
|
||||
|
||||
image orama-os.img {
|
||||
hdimage {
|
||||
gpt = true
|
||||
}
|
||||
|
||||
# EFI System Partition (systemd-boot + kernel)
|
||||
partition esp {
|
||||
image = "efi-part.vfat"
|
||||
partition-type-uuid = "c12a7328-f81f-11d2-ba4b-00a0c93ec93b"
|
||||
bootable = true
|
||||
size = 64M
|
||||
}
|
||||
|
||||
# rootfs-A (active partition)
|
||||
partition rootfs-a {
|
||||
image = "rootfs.squashfs"
|
||||
partition-type-uuid = "0fc63daf-8483-4772-8e79-3d69d8477de4"
|
||||
size = 2G
|
||||
}
|
||||
|
||||
# rootfs-B (standby for A/B updates)
|
||||
partition rootfs-b {
|
||||
partition-type-uuid = "0fc63daf-8483-4772-8e79-3d69d8477de4"
|
||||
size = 2G
|
||||
}
|
||||
|
||||
# Data partition (LUKS2 encrypted during enrollment)
|
||||
# Fills remaining disk space on the target VPS.
|
||||
partition data {
|
||||
partition-type-uuid = "0fc63daf-8483-4772-8e79-3d69d8477de4"
|
||||
size = 10G
|
||||
}
|
||||
}
|
||||
92
os/buildroot/board/orama/kernel.config
Normal file
92
os/buildroot/board/orama/kernel.config
Normal file
@ -0,0 +1,92 @@
|
||||
# OramaOS Kernel Configuration (Linux 6.6 LTS)
|
||||
# This is a minimal config — only what OramaOS needs.
|
||||
# Start from x86_64 defconfig and overlay these options.
|
||||
|
||||
# Architecture
|
||||
CONFIG_64BIT=y
|
||||
CONFIG_X86_64=y
|
||||
|
||||
# EFI boot (required for systemd-boot)
|
||||
CONFIG_EFI=y
|
||||
CONFIG_EFI_STUB=y
|
||||
CONFIG_EFI_PARTITION=y
|
||||
CONFIG_EFIVAR_FS=y
|
||||
|
||||
# WireGuard (built-in since 5.6, no compat module needed)
|
||||
CONFIG_WIREGUARD=y
|
||||
CONFIG_IP_ADVANCED_ROUTER=y
|
||||
CONFIG_NET_FOU=y
|
||||
CONFIG_NET_UDP_TUNNEL=y
|
||||
|
||||
# dm-verity (read-only rootfs integrity)
|
||||
CONFIG_BLK_DEV_DM=y
|
||||
CONFIG_DM_VERITY=y
|
||||
CONFIG_DM_VERITY_VERIFY_ROOTHASH_SIG=y
|
||||
|
||||
# dm-crypt / LUKS
|
||||
CONFIG_DM_CRYPT=y
|
||||
CONFIG_CRYPTO_AES=y
|
||||
CONFIG_CRYPTO_XTS=y
|
||||
CONFIG_CRYPTO_SHA256=y
|
||||
CONFIG_CRYPTO_SHA512=y
|
||||
CONFIG_CRYPTO_USER_API_HASH=y
|
||||
CONFIG_CRYPTO_USER_API_SKCIPHER=y
|
||||
|
||||
# Namespaces (for service sandboxing)
|
||||
CONFIG_NAMESPACES=y
|
||||
CONFIG_UTS_NS=y
|
||||
CONFIG_USER_NS=y
|
||||
CONFIG_PID_NS=y
|
||||
CONFIG_NET_NS=y
|
||||
CONFIG_IPC_NS=y
|
||||
|
||||
# Seccomp (syscall filtering)
|
||||
CONFIG_SECCOMP=y
|
||||
CONFIG_SECCOMP_FILTER=y
|
||||
|
||||
# Cgroups (resource limiting)
|
||||
CONFIG_CGROUPS=y
|
||||
CONFIG_CGROUP_DEVICE=y
|
||||
CONFIG_CGROUP_CPUACCT=y
|
||||
CONFIG_CGROUP_PIDS=y
|
||||
CONFIG_MEMCG=y
|
||||
|
||||
# Filesystem support
|
||||
CONFIG_EXT4_FS=y
|
||||
CONFIG_SQUASHFS=y
|
||||
CONFIG_SQUASHFS_XZ=y
|
||||
CONFIG_VFAT_FS=y
|
||||
|
||||
# Block devices
|
||||
CONFIG_BLK_DEV_LOOP=y
|
||||
CONFIG_VIRTIO_BLK=y
|
||||
|
||||
# Networking
|
||||
CONFIG_NET=y
|
||||
CONFIG_INET=y
|
||||
CONFIG_IPV6=y
|
||||
CONFIG_NETFILTER=y
|
||||
CONFIG_NF_CONNTRACK=y
|
||||
CONFIG_NETFILTER_XTABLES=y
|
||||
CONFIG_IP_NF_IPTABLES=y
|
||||
CONFIG_IP_NF_FILTER=y
|
||||
CONFIG_IP_NF_NAT=y
|
||||
|
||||
# VirtIO (for QEMU testing and cloud VPS)
|
||||
CONFIG_VIRTIO=y
|
||||
CONFIG_VIRTIO_PCI=y
|
||||
CONFIG_VIRTIO_NET=y
|
||||
CONFIG_VIRTIO_CONSOLE=y
|
||||
CONFIG_HW_RANDOM_VIRTIO=y
|
||||
|
||||
# Serial console (for QEMU debugging)
|
||||
CONFIG_SERIAL_8250=y
|
||||
CONFIG_SERIAL_8250_CONSOLE=y
|
||||
|
||||
# Disable unnecessary features
|
||||
# CONFIG_SOUND is not set
|
||||
# CONFIG_USB_SUPPORT is not set
|
||||
# CONFIG_WLAN is not set
|
||||
# CONFIG_BLUETOOTH is not set
|
||||
# CONFIG_NFS_FS is not set
|
||||
# CONFIG_CIFS is not set
|
||||
78
os/buildroot/board/orama/post_build.sh
Executable file
78
os/buildroot/board/orama/post_build.sh
Executable file
@ -0,0 +1,78 @@
|
||||
#!/bin/bash
|
||||
# OramaOS post-build script.
|
||||
# Runs after Buildroot builds the rootfs but before image creation.
|
||||
# $TARGET_DIR is the rootfs directory.
|
||||
set -euo pipefail
|
||||
|
||||
TARGET_DIR="$1"
|
||||
|
||||
echo "=== OramaOS post_build.sh ==="
|
||||
|
||||
# --- Remove all shell access ---
|
||||
# Operators must not have interactive access to OramaOS nodes.
|
||||
# Busybox is kept for mount/umount/etc that systemd needs,
|
||||
# but all shell entry points are removed.
|
||||
rm -f "$TARGET_DIR/bin/bash"
|
||||
rm -f "$TARGET_DIR/bin/ash"
|
||||
rm -f "$TARGET_DIR/usr/bin/ssh"
|
||||
rm -f "$TARGET_DIR/usr/sbin/sshd"
|
||||
|
||||
# Replace /bin/sh with /bin/false — any attempt to spawn a shell fails
|
||||
ln -sf /bin/false "$TARGET_DIR/bin/sh"
|
||||
|
||||
# Remove getty / login (no console login)
|
||||
rm -f "$TARGET_DIR/sbin/getty"
|
||||
rm -f "$TARGET_DIR/bin/login"
|
||||
rm -f "$TARGET_DIR/usr/bin/login"
|
||||
|
||||
# Disable all TTY gettys
|
||||
rm -f "$TARGET_DIR/etc/systemd/system/getty.target.wants/"*
|
||||
rm -f "$TARGET_DIR/etc/systemd/system/multi-user.target.wants/getty@"*
|
||||
|
||||
# --- Create service users ---
|
||||
# Each service runs under a dedicated uid/gid (defined in sandbox.go).
|
||||
for uid_name in "1001:rqlite" "1002:olric" "1003:ipfs" "1004:ipfscluster" "1005:gateway" "1006:coredns"; do
|
||||
uid="${uid_name%%:*}"
|
||||
name="${uid_name##*:}"
|
||||
echo "${name}:x:${uid}:${uid}:${name} service:/nonexistent:/bin/false" >> "$TARGET_DIR/etc/passwd"
|
||||
echo "${name}:x:${uid}:" >> "$TARGET_DIR/etc/group"
|
||||
done
|
||||
|
||||
# --- Create required directories ---
|
||||
mkdir -p "$TARGET_DIR/opt/orama/bin"
|
||||
mkdir -p "$TARGET_DIR/opt/orama/.orama/configs"
|
||||
mkdir -p "$TARGET_DIR/opt/orama/.orama/data"
|
||||
mkdir -p "$TARGET_DIR/opt/orama/.orama/logs"
|
||||
mkdir -p "$TARGET_DIR/etc/orama"
|
||||
mkdir -p "$TARGET_DIR/etc/wireguard"
|
||||
mkdir -p "$TARGET_DIR/boot/loader/entries"
|
||||
|
||||
# --- Copy pre-built binaries ---
|
||||
# These are placed here by the outer build script (scripts/build.sh).
|
||||
BINS_DIR="${BINARIES_DIR:-$TARGET_DIR/../images}"
|
||||
if [ -d "$BINS_DIR/orama-bins" ]; then
|
||||
cp "$BINS_DIR/orama-bins/orama-agent" "$TARGET_DIR/usr/bin/orama-agent"
|
||||
chmod 755 "$TARGET_DIR/usr/bin/orama-agent"
|
||||
|
||||
# Service binaries go to /opt/orama/bin/ or /usr/local/bin/
|
||||
for bin in rqlited olric-server ipfs ipfs-cluster-service coredns gateway; do
|
||||
if [ -f "$BINS_DIR/orama-bins/$bin" ]; then
|
||||
cp "$BINS_DIR/orama-bins/$bin" "$TARGET_DIR/usr/local/bin/$bin"
|
||||
chmod 755 "$TARGET_DIR/usr/local/bin/$bin"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# --- Write version file ---
|
||||
if [ -n "${ORAMA_VERSION:-}" ]; then
|
||||
echo "$ORAMA_VERSION" > "$TARGET_DIR/etc/orama-version"
|
||||
fi
|
||||
|
||||
# --- systemd-boot loader config ---
|
||||
cat > "$TARGET_DIR/boot/loader/loader.conf" <<'LOADER'
|
||||
default orama-*
|
||||
timeout 0
|
||||
console-mode max
|
||||
LOADER
|
||||
|
||||
echo "=== OramaOS post_build.sh complete ==="
|
||||
50
os/buildroot/board/orama/post_image.sh
Executable file
50
os/buildroot/board/orama/post_image.sh
Executable file
@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
# OramaOS post-image script.
|
||||
# Runs after rootfs image is created. Sets up dm-verity and final disk image.
|
||||
# $BINARIES_DIR contains the built images (rootfs.squashfs, bzImage, etc.)
|
||||
set -euo pipefail
|
||||
|
||||
BINARIES_DIR="$1"
|
||||
BOARD_DIR="$(dirname "$0")"
|
||||
|
||||
echo "=== OramaOS post_image.sh ==="
|
||||
|
||||
# --- Generate dm-verity hash tree for rootfs ---
|
||||
ROOTFS="$BINARIES_DIR/rootfs.squashfs"
|
||||
VERITY_HASH="$BINARIES_DIR/rootfs.verity"
|
||||
VERITY_TABLE="$BINARIES_DIR/rootfs.verity.table"
|
||||
|
||||
if command -v veritysetup &>/dev/null; then
|
||||
echo "Generating dm-verity hash tree..."
|
||||
veritysetup format "$ROOTFS" "$VERITY_HASH" > "$VERITY_TABLE"
|
||||
ROOT_HASH=$(grep "Root hash:" "$VERITY_TABLE" | awk '{print $3}')
|
||||
echo "dm-verity root hash: $ROOT_HASH"
|
||||
echo "$ROOT_HASH" > "$BINARIES_DIR/rootfs.roothash"
|
||||
else
|
||||
echo "WARNING: veritysetup not found, skipping dm-verity (dev build only)"
|
||||
fi
|
||||
|
||||
# --- Generate partition image using genimage ---
|
||||
if [ -f "$BOARD_DIR/genimage.cfg" ]; then
|
||||
GENIMAGE_TMP="$BINARIES_DIR/genimage.tmp"
|
||||
rm -rf "$GENIMAGE_TMP"
|
||||
genimage \
|
||||
--rootpath "$TARGET_DIR" \
|
||||
--tmppath "$GENIMAGE_TMP" \
|
||||
--inputpath "$BINARIES_DIR" \
|
||||
--outputpath "$BINARIES_DIR" \
|
||||
--config "$BOARD_DIR/genimage.cfg"
|
||||
rm -rf "$GENIMAGE_TMP"
|
||||
echo "Disk image generated: $BINARIES_DIR/orama-os.img"
|
||||
fi
|
||||
|
||||
# --- Convert to qcow2 for cloud deployment ---
|
||||
if command -v qemu-img &>/dev/null; then
|
||||
echo "Converting to qcow2..."
|
||||
qemu-img convert -f raw -O qcow2 \
|
||||
"$BINARIES_DIR/orama-os.img" \
|
||||
"$BINARIES_DIR/orama-os.qcow2"
|
||||
echo "qcow2 image: $BINARIES_DIR/orama-os.qcow2"
|
||||
fi
|
||||
|
||||
echo "=== OramaOS post_image.sh complete ==="
|
||||
@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=Orama Agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/usr/bin/orama-agent
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
# The agent is the only root process on OramaOS.
|
||||
# It manages all services in sandboxed namespaces.
|
||||
# No hardening directives — the agent needs full root access for:
|
||||
# - cryptsetup (LUKS key management)
|
||||
# - mount/umount (data partition)
|
||||
# - wg-quick (WireGuard interface)
|
||||
# - clone(CLONE_NEWNS|CLONE_NEWUTS) (namespace creation)
|
||||
# - setting uid/gid for child processes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
74
os/buildroot/configs/orama_defconfig
Normal file
74
os/buildroot/configs/orama_defconfig
Normal file
@ -0,0 +1,74 @@
|
||||
# OramaOS Buildroot defconfig
|
||||
# Minimal, locked-down Linux image for Orama Network nodes.
|
||||
# No SSH, no shell, no operator access. Only the orama-agent runs as root.
|
||||
|
||||
# Architecture
|
||||
BR2_x86_64=y
|
||||
|
||||
# Toolchain
|
||||
BR2_TOOLCHAIN_EXTERNAL=y
|
||||
BR2_TOOLCHAIN_EXTERNAL_DOWNLOAD=y
|
||||
|
||||
# Kernel
|
||||
BR2_LINUX_KERNEL=y
|
||||
BR2_LINUX_KERNEL_CUSTOM_VERSION=y
|
||||
BR2_LINUX_KERNEL_CUSTOM_VERSION_VALUE="6.6.70"
|
||||
BR2_LINUX_KERNEL_USE_CUSTOM_CONFIG=y
|
||||
BR2_LINUX_KERNEL_CUSTOM_CONFIG_FILE="board/orama/kernel.config"
|
||||
BR2_LINUX_KERNEL_INSTALL_TARGET=y
|
||||
BR2_LINUX_KERNEL_NEEDS_HOST_OPENSSL=y
|
||||
|
||||
# Init system: systemd
|
||||
BR2_INIT_SYSTEMD=y
|
||||
BR2_PACKAGE_SYSTEMD_BOOTD=y
|
||||
|
||||
# Rootfs: SquashFS (read-only, used with dm-verity)
|
||||
BR2_TARGET_ROOTFS_SQUASHFS=y
|
||||
BR2_TARGET_ROOTFS_SQUASHFS_4_0=y
|
||||
|
||||
# Required packages for LUKS + boot
|
||||
BR2_PACKAGE_UTIL_LINUX=y
|
||||
BR2_PACKAGE_UTIL_LINUX_MOUNT=y
|
||||
BR2_PACKAGE_UTIL_LINUX_UMOUNT=y
|
||||
BR2_PACKAGE_KMOD=y
|
||||
BR2_PACKAGE_CRYPTSETUP=y
|
||||
BR2_PACKAGE_LVM2=y
|
||||
|
||||
# Busybox: keep for systemd compatibility, but shell removed in post_build.sh
|
||||
BR2_PACKAGE_BUSYBOX=y
|
||||
|
||||
# WireGuard tools (kernel module is built-in since 6.6)
|
||||
BR2_PACKAGE_WIREGUARD_TOOLS=y
|
||||
|
||||
# Network utilities
|
||||
BR2_PACKAGE_IPROUTE2=y
|
||||
BR2_PACKAGE_IPTABLES=y
|
||||
|
||||
# Certificate authorities for HTTPS
|
||||
BR2_PACKAGE_CA_CERTIFICATES=y
|
||||
|
||||
# No SSH — this is intentional. Operators must not have shell access.
|
||||
# BR2_PACKAGE_OPENSSH is not set
|
||||
# BR2_PACKAGE_DROPBEAR is not set
|
||||
|
||||
# No package manager
|
||||
# BR2_PACKAGE_OPKG is not set
|
||||
|
||||
# Post-build scripts
|
||||
BR2_ROOTFS_POST_BUILD_SCRIPT="board/orama/post_build.sh"
|
||||
BR2_ROOTFS_POST_IMAGE_SCRIPT="board/orama/post_image.sh"
|
||||
BR2_ROOTFS_POST_SCRIPT_ARGS=""
|
||||
|
||||
# Overlay
|
||||
BR2_ROOTFS_OVERLAY="board/orama/rootfs_overlay"
|
||||
|
||||
# Image generation
|
||||
BR2_ROOTFS_POST_IMAGE_SCRIPT="board/orama/post_image.sh"
|
||||
|
||||
# Host tools needed for image generation
|
||||
BR2_PACKAGE_HOST_GENIMAGE=y
|
||||
BR2_PACKAGE_HOST_MTOOLS=y
|
||||
|
||||
# Timezone
|
||||
BR2_TARGET_TZ_INFO=y
|
||||
BR2_TARGET_LOCALTIME="UTC"
|
||||
19
os/buildroot/external/orama-agent/orama-agent.mk
vendored
Normal file
19
os/buildroot/external/orama-agent/orama-agent.mk
vendored
Normal file
@ -0,0 +1,19 @@
|
||||
# orama-agent Buildroot external package
|
||||
# The agent binary is cross-compiled externally and copied into the rootfs
|
||||
# by post_build.sh. This .mk is a placeholder for Buildroot's package system
|
||||
# in case we want to integrate the Go build into Buildroot later.
|
||||
|
||||
ORAMA_AGENT_VERSION = 1.0.0
|
||||
ORAMA_AGENT_SITE = $(TOPDIR)/../agent
|
||||
ORAMA_AGENT_SITE_METHOD = local
|
||||
|
||||
define ORAMA_AGENT_BUILD_CMDS
|
||||
cd $(@D) && GOOS=linux GOARCH=amd64 CGO_ENABLED=0 \
|
||||
go build -o $(@D)/orama-agent ./cmd/orama-agent/
|
||||
endef
|
||||
|
||||
define ORAMA_AGENT_INSTALL_TARGET_CMDS
|
||||
install -D -m 0755 $(@D)/orama-agent $(TARGET_DIR)/usr/bin/orama-agent
|
||||
endef
|
||||
|
||||
$(eval $(generic-package))
|
||||
125
os/scripts/build.sh
Executable file
125
os/scripts/build.sh
Executable file
@ -0,0 +1,125 @@
|
||||
#!/bin/bash
|
||||
# OramaOS full image build script.
|
||||
#
|
||||
# Prerequisites:
|
||||
# - Go 1.23+ installed
|
||||
# - Buildroot downloaded (set BUILDROOT_SRC or it clones automatically)
|
||||
# - Host tools: genimage, qemu-img (optional), veritysetup (optional)
|
||||
#
|
||||
# Usage:
|
||||
# ORAMA_VERSION=1.0.0 ARCH=amd64 ./scripts/build.sh
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
VERSION="${ORAMA_VERSION:-dev}"
|
||||
ARCH="${ARCH:-amd64}"
|
||||
OUTPUT_DIR="$ROOT_DIR/output"
|
||||
BUILDROOT_DIR="$ROOT_DIR/buildroot"
|
||||
BUILDROOT_SRC="${BUILDROOT_SRC:-$ROOT_DIR/.buildroot}"
|
||||
BUILDROOT_VERSION="2024.02.9"
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
echo "=== OramaOS Build ==="
|
||||
echo "Version: $VERSION"
|
||||
echo "Arch: $ARCH"
|
||||
echo "Output: $OUTPUT_DIR"
|
||||
echo ""
|
||||
|
||||
# --- Step 1: Cross-compile orama-agent ---
|
||||
echo "--- Step 1: Building orama-agent ---"
|
||||
cd "$ROOT_DIR/agent"
|
||||
GOOS=linux GOARCH="$ARCH" CGO_ENABLED=0 \
|
||||
go build -ldflags "-s -w -X main.version=$VERSION" \
|
||||
-o "$OUTPUT_DIR/orama-agent" ./cmd/orama-agent/
|
||||
echo "Built: orama-agent"
|
||||
|
||||
# --- Step 2: Collect service binaries ---
|
||||
echo "--- Step 2: Collecting service binaries ---"
|
||||
BINS_DIR="$OUTPUT_DIR/orama-bins"
|
||||
mkdir -p "$BINS_DIR"
|
||||
|
||||
# Copy the agent
|
||||
cp "$OUTPUT_DIR/orama-agent" "$BINS_DIR/orama-agent"
|
||||
|
||||
# If the main orama project has been built, collect its binaries
|
||||
ORAMA_BUILD="${ORAMA_BUILD_DIR:-$ROOT_DIR/../orama}"
|
||||
if [ -d "$ORAMA_BUILD/build" ]; then
|
||||
echo "Copying service binaries from $ORAMA_BUILD/build/"
|
||||
for bin in rqlited olric-server ipfs ipfs-cluster-service coredns gateway; do
|
||||
src="$ORAMA_BUILD/build/$bin"
|
||||
if [ -f "$src" ]; then
|
||||
cp "$src" "$BINS_DIR/$bin"
|
||||
echo " Copied: $bin"
|
||||
else
|
||||
echo " WARNING: $bin not found in $ORAMA_BUILD/build/"
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo "WARNING: orama build dir not found at $ORAMA_BUILD/build/"
|
||||
echo " Service binaries must be placed in $BINS_DIR/ manually."
|
||||
fi
|
||||
|
||||
# --- Step 3: Download Buildroot if needed ---
|
||||
if [ ! -d "$BUILDROOT_SRC" ]; then
|
||||
echo "--- Step 3: Downloading Buildroot $BUILDROOT_VERSION ---"
|
||||
TARBALL="buildroot-$BUILDROOT_VERSION.tar.xz"
|
||||
wget -q "https://buildroot.org/downloads/$TARBALL" -O "/tmp/$TARBALL"
|
||||
mkdir -p "$BUILDROOT_SRC"
|
||||
tar xf "/tmp/$TARBALL" -C "$BUILDROOT_SRC" --strip-components=1
|
||||
rm "/tmp/$TARBALL"
|
||||
else
|
||||
echo "--- Step 3: Using existing Buildroot at $BUILDROOT_SRC ---"
|
||||
fi
|
||||
|
||||
# --- Step 4: Configure and build with Buildroot ---
|
||||
echo "--- Step 4: Running Buildroot ---"
|
||||
BUILD_OUTPUT="$OUTPUT_DIR/buildroot-build"
|
||||
mkdir -p "$BUILD_OUTPUT"
|
||||
|
||||
# Copy our board and config files into the Buildroot tree
|
||||
cp -r "$BUILDROOT_DIR/board" "$BUILDROOT_SRC/"
|
||||
cp -r "$BUILDROOT_DIR/configs" "$BUILDROOT_SRC/"
|
||||
|
||||
# Place binaries where post_build.sh expects them
|
||||
mkdir -p "$BUILD_OUTPUT/images/orama-bins"
|
||||
cp "$BINS_DIR"/* "$BUILD_OUTPUT/images/orama-bins/" 2>/dev/null || true
|
||||
|
||||
# Set version for post_build.sh
|
||||
export ORAMA_VERSION="$VERSION"
|
||||
export BINARIES_DIR="$BUILD_OUTPUT/images"
|
||||
|
||||
# Run Buildroot
|
||||
cd "$BUILDROOT_SRC"
|
||||
make O="$BUILD_OUTPUT" orama_defconfig
|
||||
make O="$BUILD_OUTPUT" -j"$(nproc)"
|
||||
|
||||
# --- Step 5: Copy final artifacts ---
|
||||
echo "--- Step 5: Copying artifacts ---"
|
||||
FINAL_PREFIX="orama-os-${VERSION}-${ARCH}"
|
||||
|
||||
if [ -f "$BUILD_OUTPUT/images/orama-os.img" ]; then
|
||||
cp "$BUILD_OUTPUT/images/orama-os.img" "$OUTPUT_DIR/${FINAL_PREFIX}.img"
|
||||
echo "Raw image: $OUTPUT_DIR/${FINAL_PREFIX}.img"
|
||||
fi
|
||||
|
||||
if [ -f "$BUILD_OUTPUT/images/orama-os.qcow2" ]; then
|
||||
cp "$BUILD_OUTPUT/images/orama-os.qcow2" "$OUTPUT_DIR/${FINAL_PREFIX}.qcow2"
|
||||
echo "qcow2: $OUTPUT_DIR/${FINAL_PREFIX}.qcow2"
|
||||
fi
|
||||
|
||||
if [ -f "$BUILD_OUTPUT/images/rootfs.roothash" ]; then
|
||||
cp "$BUILD_OUTPUT/images/rootfs.roothash" "$OUTPUT_DIR/${FINAL_PREFIX}.roothash"
|
||||
echo "Root hash: $OUTPUT_DIR/${FINAL_PREFIX}.roothash"
|
||||
fi
|
||||
|
||||
# Generate SHA256 checksums
|
||||
cd "$OUTPUT_DIR"
|
||||
sha256sum "${FINAL_PREFIX}"* > "${FINAL_PREFIX}.sha256"
|
||||
echo "Checksums: $OUTPUT_DIR/${FINAL_PREFIX}.sha256"
|
||||
|
||||
echo ""
|
||||
echo "=== OramaOS Build Complete ==="
|
||||
echo "Artifacts in $OUTPUT_DIR/"
|
||||
59
os/scripts/sign.sh
Executable file
59
os/scripts/sign.sh
Executable file
@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
# Sign OramaOS image artifacts with rootwallet.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/sign.sh output/orama-os-1.0.0-amd64
|
||||
#
|
||||
# This signs the checksum file, producing a .sig file that can be verified
|
||||
# with the embedded public key on nodes.
|
||||
set -euo pipefail
|
||||
|
||||
PREFIX="$1"
|
||||
|
||||
if [ -z "$PREFIX" ]; then
|
||||
echo "Usage: $0 <artifact-prefix>"
|
||||
echo " e.g.: $0 output/orama-os-1.0.0-amd64"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
CHECKSUM_FILE="${PREFIX}.sha256"
|
||||
if [ ! -f "$CHECKSUM_FILE" ]; then
|
||||
echo "Error: checksum file not found: $CHECKSUM_FILE"
|
||||
echo "Run 'make build' first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Compute hash of the checksum file
|
||||
HASH=$(sha256sum "$CHECKSUM_FILE" | awk '{print $1}')
|
||||
echo "Signing hash: $HASH"
|
||||
|
||||
# Sign with rootwallet (EVM secp256k1 personal_sign)
|
||||
if ! command -v rw &>/dev/null; then
|
||||
echo "Error: 'rw' (rootwallet CLI) not found in PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SIGNATURE=$(rw sign "$HASH" --chain evm 2>&1)
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error: rw sign failed: $SIGNATURE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Write signature file
|
||||
SIG_FILE="${PREFIX}.sig"
|
||||
echo "$SIGNATURE" > "$SIG_FILE"
|
||||
echo "Signature written: $SIG_FILE"
|
||||
|
||||
# Verify the signature
|
||||
echo "Verifying signature..."
|
||||
VERIFY=$(rw verify "$HASH" "$SIGNATURE" --chain evm 2>&1)
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "WARNING: Signature verification failed: $VERIFY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Signature verified successfully."
|
||||
echo ""
|
||||
echo "Artifacts:"
|
||||
echo " Checksum: $CHECKSUM_FILE"
|
||||
echo " Signature: $SIG_FILE"
|
||||
62
os/scripts/test-vm.sh
Executable file
62
os/scripts/test-vm.sh
Executable file
@ -0,0 +1,62 @@
|
||||
#!/bin/bash
|
||||
# Launch OramaOS in a QEMU VM for testing.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-vm.sh output/orama-os.qcow2
|
||||
#
|
||||
# The VM runs with:
|
||||
# - 2 CPUs, 2GB RAM
|
||||
# - VirtIO disk, network
|
||||
# - Serial console (for debugging)
|
||||
# - Host network (user mode) with port forwarding:
|
||||
# - 9999 → 9999 (enrollment server)
|
||||
# - 9998 → 9998 (command receiver)
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="${1:-output/orama-os.qcow2}"
|
||||
|
||||
if [ ! -f "$IMAGE" ]; then
|
||||
echo "Error: image not found: $IMAGE"
|
||||
echo "Run 'make build' first, or specify image path."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create a temporary copy to avoid modifying the original
|
||||
WORK_IMAGE="/tmp/orama-os-test-$(date +%s).qcow2"
|
||||
cp "$IMAGE" "$WORK_IMAGE"
|
||||
|
||||
echo "=== OramaOS Test VM ==="
|
||||
echo "Image: $IMAGE"
|
||||
echo "Working copy: $WORK_IMAGE"
|
||||
echo ""
|
||||
echo "Port forwarding:"
|
||||
echo " localhost:9999 → VM:9999 (enrollment)"
|
||||
echo " localhost:9998 → VM:9998 (commands)"
|
||||
echo ""
|
||||
echo "Press Ctrl-A X to exit QEMU."
|
||||
echo ""
|
||||
|
||||
qemu-system-x86_64 \
|
||||
-enable-kvm \
|
||||
-cpu host \
|
||||
-smp 2 \
|
||||
-m 2G \
|
||||
-drive file="$WORK_IMAGE",format=qcow2,if=virtio \
|
||||
-netdev user,id=net0,hostfwd=tcp::9999-:9999,hostfwd=tcp::9998-:9998 \
|
||||
-device virtio-net-pci,netdev=net0 \
|
||||
-nographic \
|
||||
-serial mon:stdio \
|
||||
-bios /usr/share/ovmf/OVMF.fd 2>/dev/null || \
|
||||
qemu-system-x86_64 \
|
||||
-cpu max \
|
||||
-smp 2 \
|
||||
-m 2G \
|
||||
-drive file="$WORK_IMAGE",format=qcow2,if=virtio \
|
||||
-netdev user,id=net0,hostfwd=tcp::9999-:9999,hostfwd=tcp::9998-:9998 \
|
||||
-device virtio-net-pci,netdev=net0 \
|
||||
-nographic \
|
||||
-serial mon:stdio
|
||||
|
||||
# Clean up
|
||||
rm -f "$WORK_IMAGE"
|
||||
echo "Cleaned up working copy."
|
||||
Loading…
x
Reference in New Issue
Block a user