diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c9ae831 --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/cmd/console.go b/cmd/console.go new file mode 100644 index 0000000..5309c34 --- /dev/null +++ b/cmd/console.go @@ -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) +} diff --git a/console.stim b/console.stim new file mode 100644 index 0000000..831e062 Binary files /dev/null and b/console.stim differ diff --git a/pkg/console/browser.go b/pkg/console/browser.go new file mode 100644 index 0000000..3469a34 --- /dev/null +++ b/pkg/console/browser.go @@ -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() +} diff --git a/pkg/console/server.go b/pkg/console/server.go new file mode 100644 index 0000000..24353af --- /dev/null +++ b/pkg/console/server.go @@ -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) +} diff --git a/pkg/console/unlock.html b/pkg/console/unlock.html new file mode 100644 index 0000000..5f4db39 --- /dev/null +++ b/pkg/console/unlock.html @@ -0,0 +1,253 @@ + + +
+ + +Enter password to unlock encrypted content
+