feat: Add Borg Console and release workflow

This commit is contained in:
Snider 2025-12-27 02:32:31 +00:00
parent b3755da69d
commit 741bbe11e8
6 changed files with 687 additions and 0 deletions

105
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,105 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Get version
id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
- name: Build binaries
run: |
mkdir -p dist
# Linux amd64
GOOS=linux GOARCH=amd64 go build -ldflags "-s -w" -o dist/borg-linux-amd64 main.go
# Linux arm64
GOOS=linux GOARCH=arm64 go build -ldflags "-s -w" -o dist/borg-linux-arm64 main.go
# macOS amd64
GOOS=darwin GOARCH=amd64 go build -ldflags "-s -w" -o dist/borg-darwin-amd64 main.go
# macOS arm64
GOOS=darwin GOARCH=arm64 go build -ldflags "-s -w" -o dist/borg-darwin-arm64 main.go
# Windows amd64
GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -o dist/borg-windows-amd64.exe main.go
- name: Build WASM module
run: |
GOOS=js GOARCH=wasm go build -o dist/stmf.wasm ./pkg/wasm/stmf/
cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" dist/ 2>/dev/null || \
cp "$(go env GOROOT)/lib/wasm/wasm_exec.js" dist/
- name: Build Console STIM
run: |
# Build borg for current platform first
go build -o borg main.go
# Build the encrypted console demo
./borg console build -p "borg-demo" -o dist/console.stim -s js/borg-stmf
- name: Create checksums
run: |
cd dist
sha256sum * > checksums.txt
- name: Create Release
uses: softprops/action-gh-release@v1
with:
name: Borg ${{ steps.version.outputs.VERSION }}
body: |
## Borg ${{ steps.version.outputs.VERSION }}
### Downloads
| Platform | Binary |
|----------|--------|
| Linux x64 | `borg-linux-amd64` |
| Linux ARM64 | `borg-linux-arm64` |
| macOS x64 | `borg-darwin-amd64` |
| macOS ARM64 | `borg-darwin-arm64` |
| Windows x64 | `borg-windows-amd64.exe` |
### Console Demo
The `console.stim` is an encrypted PWA demo. Run it with:
```bash
borg console serve console.stim --open
```
Password: `borg-demo`
### WASM Module
- `stmf.wasm` - Browser encryption module
- `wasm_exec.js` - Go WASM runtime
files: |
dist/borg-linux-amd64
dist/borg-linux-arm64
dist/borg-darwin-amd64
dist/borg-darwin-arm64
dist/borg-windows-amd64.exe
dist/stmf.wasm
dist/wasm_exec.js
dist/console.stim
dist/checksums.txt
draft: false
prerelease: false

163
cmd/console.go Normal file
View file

@ -0,0 +1,163 @@
package cmd
import (
"fmt"
"os"
"path/filepath"
"github.com/Snider/Borg/pkg/console"
"github.com/Snider/Borg/pkg/tim"
"github.com/spf13/cobra"
)
var consoleCmd = NewConsoleCmd()
// NewConsoleCmd creates the console parent command.
func NewConsoleCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "console",
Short: "Manage encrypted PWA console demos",
Long: `The Borg Console packages and serves encrypted PWA demos.
Build a console STIM:
borg console build -p "password" -o console.stim
Serve with unlock page:
borg console serve console.stim --open
Serve pre-unlocked:
borg console serve console.stim -p "password" --open`,
}
cmd.AddCommand(NewConsoleBuildCmd())
cmd.AddCommand(NewConsoleServeCmd())
return cmd
}
// NewConsoleBuildCmd creates the build subcommand.
func NewConsoleBuildCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "build",
Short: "Build a console STIM from demo files",
Long: `Packages HTML demo files into an encrypted STIM container.
By default, looks for files in js/borg-stmf/ directory.
Required files: index.html, support-reply.html, stmf.wasm, wasm_exec.js`,
RunE: func(cmd *cobra.Command, args []string) error {
password, _ := cmd.Flags().GetString("password")
output, _ := cmd.Flags().GetString("output")
sourceDir, _ := cmd.Flags().GetString("source")
if password == "" {
return fmt.Errorf("password is required")
}
// Create new TIM
m, err := tim.New()
if err != nil {
return fmt.Errorf("creating TIM: %w", err)
}
// Required demo files
files := []string{
"index.html",
"support-reply.html",
"stmf.wasm",
"wasm_exec.js",
}
// Add each file to the TIM
for _, f := range files {
path := filepath.Join(sourceDir, f)
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading %s: %w", f, err)
}
m.RootFS.AddData(f, data)
fmt.Printf(" + %s (%d bytes)\n", f, len(data))
}
// Encrypt to STIM
stim, err := m.ToSigil(password)
if err != nil {
return fmt.Errorf("encrypting STIM: %w", err)
}
// Write output
if err := os.WriteFile(output, stim, 0644); err != nil {
return fmt.Errorf("writing output: %w", err)
}
fmt.Printf("\nBuilt: %s (%d bytes)\n", output, len(stim))
fmt.Println("Encrypted with ChaCha20-Poly1305")
return nil
},
}
cmd.Flags().StringP("password", "p", "", "Encryption password (required)")
cmd.Flags().StringP("output", "o", "console.stim", "Output file")
cmd.Flags().StringP("source", "s", "js/borg-stmf", "Source directory")
cmd.MarkFlagRequired("password")
return cmd
}
// NewConsoleServeCmd creates the serve subcommand.
func NewConsoleServeCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "serve [stim-file]",
Short: "Serve an encrypted console STIM",
Long: `Starts an HTTP server to serve encrypted STIM content.
Without a password, shows a dark-themed unlock page.
With a password, decrypts immediately and serves content.
Examples:
borg console serve demos.stim --open
borg console serve demos.stim -p "password" --port 3000`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
stimPath := args[0]
password, _ := cmd.Flags().GetString("password")
port, _ := cmd.Flags().GetString("port")
openBrowser, _ := cmd.Flags().GetBool("open")
// Create server
server, err := console.NewServer(stimPath, password, port)
if err != nil {
return err
}
// Print status
fmt.Printf("Borg Console serving at %s\n", server.URL())
if password != "" {
fmt.Println("Status: Unlocked (password provided)")
} else {
fmt.Println("Status: Locked (unlock page active)")
}
fmt.Println()
// Open browser if requested
if openBrowser {
if err := console.OpenBrowser(server.URL()); err != nil {
fmt.Printf("Warning: could not open browser: %v\n", err)
}
}
// Start serving
return server.Start()
},
}
cmd.Flags().StringP("password", "p", "", "Decryption password (skip unlock page)")
cmd.Flags().String("port", "8080", "Port to serve on")
cmd.Flags().Bool("open", false, "Auto-open browser")
return cmd
}
func init() {
RootCmd.AddCommand(consoleCmd)
}

BIN
console.stim Normal file

Binary file not shown.

27
pkg/console/browser.go Normal file
View file

@ -0,0 +1,27 @@
// Package console provides an encrypted PWA demo server with browser integration.
package console
import (
"fmt"
"os/exec"
"runtime"
)
// OpenBrowser opens the default browser to the specified URL.
// Supports macOS, Linux, and Windows.
func OpenBrowser(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "linux":
cmd = exec.Command("xdg-open", url)
case "windows":
cmd = exec.Command("cmd", "/c", "start", url)
default:
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}
return cmd.Start()
}

139
pkg/console/server.go Normal file
View file

@ -0,0 +1,139 @@
package console
import (
_ "embed"
"fmt"
"net/http"
"net/url"
"os"
"sync"
"github.com/Snider/Borg/pkg/datanode"
"github.com/Snider/Borg/pkg/tim"
)
//go:embed unlock.html
var unlockHTML []byte
// Server serves encrypted STIM content with an optional unlock page.
type Server struct {
stimData []byte
password string
port string
mu sync.RWMutex
unlocked bool
rootFS *datanode.DataNode
}
// NewServer creates a new console server.
// If password is provided, the content is decrypted immediately.
// If password is empty, an unlock page is shown until the user provides the password.
func NewServer(stimPath, password, port string) (*Server, error) {
data, err := os.ReadFile(stimPath)
if err != nil {
return nil, fmt.Errorf("reading STIM file: %w", err)
}
s := &Server{
stimData: data,
password: password,
port: port,
}
// If password provided, unlock immediately
if password != "" {
if err := s.unlock(password); err != nil {
return nil, fmt.Errorf("decrypting STIM: %w", err)
}
}
return s, nil
}
// unlock decrypts the STIM data with the given password.
func (s *Server) unlock(password string) error {
s.mu.Lock()
defer s.mu.Unlock()
m, err := tim.FromSigil(s.stimData, password)
if err != nil {
return err
}
s.rootFS = m.RootFS
s.unlocked = true
return nil
}
// isUnlocked returns whether the content has been decrypted.
func (s *Server) isUnlocked() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.unlocked
}
// Start begins serving HTTP requests.
func (s *Server) Start() error {
http.HandleFunc("/", s.handleRoot)
http.HandleFunc("/unlock", s.handleUnlock)
return http.ListenAndServe(":"+s.port, nil)
}
// handleRoot serves the main content or unlock page.
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
if !s.isUnlocked() {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(unlockHTML)
return
}
s.mu.RLock()
fs := http.FS(s.rootFS)
s.mu.RUnlock()
http.FileServer(fs).ServeHTTP(w, r)
}
// handleUnlock processes the unlock form submission.
func (s *Server) handleUnlock(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
redirectWithError(w, r, "Invalid form submission")
return
}
password := r.FormValue("password")
if password == "" {
redirectWithError(w, r, "Password is required")
return
}
if err := s.unlock(password); err != nil {
redirectWithError(w, r, "Incorrect password")
return
}
// Success - redirect to content
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// redirectWithError redirects to the unlock page with an error message.
func redirectWithError(w http.ResponseWriter, r *http.Request, message string) {
http.Redirect(w, r, "/?error="+url.QueryEscape(message), http.StatusSeeOther)
}
// Port returns the server's port.
func (s *Server) Port() string {
return s.port
}
// URL returns the full server URL.
func (s *Server) URL() string {
return fmt.Sprintf("http://localhost:%s", s.port)
}

253
pkg/console/unlock.html Normal file
View file

@ -0,0 +1,253 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Borg Console - Unlock</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
color: #e0e0e0;
}
.container {
width: 100%;
max-width: 420px;
}
.card {
background: rgba(255,255,255,0.05);
border-radius: 16px;
padding: 2.5rem;
border: 1px solid rgba(255,255,255,0.1);
backdrop-filter: blur(10px);
}
.logo {
text-align: center;
margin-bottom: 1.5rem;
}
.logo-icon {
font-size: 3rem;
margin-bottom: 0.5rem;
}
h1 {
text-align: center;
font-size: 1.8rem;
margin-bottom: 0.5rem;
background: linear-gradient(90deg, #00d9ff, #00ff94);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.subtitle {
text-align: center;
color: #888;
margin-bottom: 2rem;
font-size: 0.9rem;
}
.input-group {
margin-bottom: 1.5rem;
}
label {
display: block;
margin-bottom: 0.5rem;
color: #aaa;
font-size: 0.85rem;
}
input[type="password"] {
width: 100%;
padding: 1rem;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 8px;
background: rgba(0,0,0,0.3);
color: #fff;
font-size: 1rem;
transition: all 0.2s;
}
input[type="password"]:focus {
outline: none;
border-color: #00d9ff;
box-shadow: 0 0 0 3px rgba(0, 217, 255, 0.1);
}
input[type="password"]::placeholder {
color: #666;
}
button {
width: 100%;
padding: 1rem;
border: none;
border-radius: 8px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
font-size: 1rem;
background: linear-gradient(135deg, #00d9ff 0%, #00ff94 100%);
color: #000;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(0, 217, 255, 0.4);
}
button:active {
transform: translateY(0);
}
.error-banner {
background: rgba(255, 82, 82, 0.1);
border: 1px solid rgba(255, 82, 82, 0.3);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1.5rem;
color: #ff5252;
display: none;
text-align: center;
font-size: 0.9rem;
}
.error-banner.visible {
display: block;
}
.footer {
text-align: center;
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid rgba(255,255,255,0.1);
}
.footer p {
font-size: 0.75rem;
color: #666;
}
.footer code {
background: rgba(0,0,0,0.3);
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', monospace;
}
.loading {
display: none;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.loading.visible {
display: flex;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(0, 217, 255, 0.3);
border-top-color: #00d9ff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.btn-text {
display: block;
}
.btn-text.hidden {
display: none;
}
</style>
</head>
<body>
<div class="container">
<div class="card">
<div class="logo">
<div class="logo-icon">🔐</div>
<h1>Borg Console</h1>
<p class="subtitle">Enter password to unlock encrypted content</p>
</div>
<div id="error-banner" class="error-banner"></div>
<form id="unlock-form" method="POST" action="/unlock">
<div class="input-group">
<label for="password">Password</label>
<input
type="password"
id="password"
name="password"
placeholder="Enter your password"
autocomplete="current-password"
required
autofocus
>
</div>
<button type="submit" id="submit-btn">
<span class="btn-text" id="btn-text">Unlock Console</span>
<span class="loading" id="loading">
<span class="spinner"></span>
<span>Decrypting...</span>
</span>
</button>
</form>
<div class="footer">
<p>Encrypted with <code>ChaCha20-Poly1305</code></p>
</div>
</div>
</div>
<script>
const form = document.getElementById('unlock-form');
const errorBanner = document.getElementById('error-banner');
const btnText = document.getElementById('btn-text');
const loading = document.getElementById('loading');
const submitBtn = document.getElementById('submit-btn');
// Check for error in URL params
const params = new URLSearchParams(window.location.search);
const error = params.get('error');
if (error) {
errorBanner.textContent = error;
errorBanner.classList.add('visible');
// Clean URL
history.replaceState(null, '', window.location.pathname);
}
form.addEventListener('submit', function(e) {
// Show loading state
btnText.classList.add('hidden');
loading.classList.add('visible');
submitBtn.disabled = true;
errorBanner.classList.remove('visible');
});
</script>
</body>
</html>