diff --git a/playground/Makefile b/playground/Makefile new file mode 100644 index 0000000..2aad2df --- /dev/null +++ b/playground/Makefile @@ -0,0 +1,55 @@ +# Bux Playground — Makefile +# Run from project root: make -C playground + +.PHONY: all build-sandbox build-backend run stop clean + +SANDBOX_IMAGE = bux-playground-sandbox +BACKEND_IMAGE = bux-playground-backend +PORT = 8080 + +all: build-sandbox build-backend + +# Build the sandbox Docker image (needs buxc2 + lib + rt) +build-sandbox: + @echo "Building sandbox image..." + @docker build \ + -f sandbox/Dockerfile \ + -t $(SANDBOX_IMAGE) \ + .. + +# Build the backend Go server +build-backend: + @echo "Building backend image..." + @docker build \ + -f backend/Dockerfile \ + -t $(BACKEND_IMAGE) \ + . + +# Run everything with docker-compose +run: + @echo "Starting Bux Playground on http://localhost:$(PORT)" + @docker-compose up + +# Run in background +run-detached: + @echo "Starting Bux Playground in background on http://localhost:$(PORT)" + @docker-compose up -d + +# Stop +stop: + @docker-compose down + +# Clean images and containers +clean: + @docker-compose down --rmi all -v + @docker rmi $(SANDBOX_IMAGE) $(BACKEND_IMAGE) 2>/dev/null || true + +# Development mode (local backend, no Docker sandbox) +dev: + @echo "Starting backend in dev mode..." + cd backend && go run main.go + +# Quick test +test: + @echo "Testing /health endpoint..." + @curl -s http://localhost:$(PORT)/health | jq . diff --git a/playground/README.md b/playground/README.md new file mode 100644 index 0000000..80d613f --- /dev/null +++ b/playground/README.md @@ -0,0 +1,165 @@ +# Bux Playground + +Interactive web playground for the Bux programming language. Compile and run Bux code directly in your browser. + +## Architecture + +``` +┌─────────────┐ POST /compile ┌──────────────┐ docker run ┌─────────────┐ +│ Browser │ ────────────────────► │ Go Backend │ ────────────────► │ Sandbox │ +│ (Monaco) │ ◄─── JSON output │ (8080) │ │ (Docker) │ +└─────────────┘ └──────────────┘ └─────────────┘ +``` + +## Quick Start + +```bash +# 1. Build both images (from project root) +make -C playground all + +# 2. Start the playground +make -C playground run + +# 3. Open http://localhost:8080 +``` + +## Development Mode + +```bash +# Run backend locally without Docker sandbox +# (requires buxc2 in PATH) +cd playground/backend +go run main.go +``` + +Then open `playground/frontend/index.html` directly in browser. + +## Deployment on VPS + +### 1. Build and copy to VPS + +```bash +# Local machine +make selfhost # Build buxc2 +make -C playground all # Build Docker images +docker save bux-playground-sandbox | gzip > sandbox.tar.gz +docker save bux-playground-backend | gzip > backend.tar.gz + +# Copy to VPS +scp sandbox.tar.gz backend.tar.gz user@vps:/tmp/ +``` + +### 2. On VPS + +```bash +# Load images +docker load < /tmp/sandbox.tar.gz +docker load < /tmp/backend.tar.gz + +# Run with docker-compose +cd /opt/bux-playground +docker-compose up -d +``` + +### 3. nginx reverse proxy + SSL + +```nginx +server { + listen 80; + server_name play.bux-lang.org; + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl http2; + server_name play.bux-lang.org; + + ssl_certificate /etc/letsencrypt/live/play.bux-lang.org/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/play.bux-lang.org/privkey.pem; + + location / { + proxy_pass http://localhost:8080; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + } +} +``` + +### 4. systemd service + +```ini +# /etc/systemd/system/bux-playground.service +[Unit] +Description=Bux Playground +After=docker.service +Requires=docker.service + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/opt/bux-playground +ExecStart=/usr/bin/docker-compose up -d +ExecStop=/usr/bin/docker-compose down + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl enable bux-playground +sudo systemctl start bux-playground +``` + +## Security + +- **Sandbox**: Each compile runs in a fresh Docker container with: + - No network access (`--network=none`) + - 128MB memory limit + - 1 CPU core limit + - 64 process limit + - Read-only filesystem + - `timeout 5s` for program execution + - Temporary files auto-deleted + +- **Code limits**: + - Max code size: 64KB + - Max output size: 64KB + - Compile timeout: 10s + - Run timeout: 5s + +## Environment Variables + +| Variable | Default | Description | +|----------|---------|-------------| +| `PORT` | `8080` | HTTP server port | +| `USE_DOCKER` | `1` | Use Docker sandbox (0 = local mode) | +| `SANDBOX_IMAGE` | `bux-playground-sandbox` | Docker image name | +| `BUXC2_PATH` | `buxc2` | Path to buxc2 binary (local mode) | + +## API + +### POST /compile + +Compile and run Bux code. + +**Request:** `text/plain` — Bux source code + +**Response:** +```json +{ + "output": "Hello, Bux!\n", + "isError": false +} +``` + +### GET /health + +Health check endpoint. + +**Response:** +```json +{"status": "ok"} +``` diff --git a/playground/backend/Dockerfile b/playground/backend/Dockerfile new file mode 100644 index 0000000..7cd29d8 --- /dev/null +++ b/playground/backend/Dockerfile @@ -0,0 +1,18 @@ +FROM golang:1.22-alpine AS builder + +WORKDIR /app +COPY main.go . +RUN go mod init bux-playground 2>/dev/null || true +RUN go build -o playground-server main.go + +FROM alpine:latest + +RUN apk add --no-cache ca-certificates docker-cli + +WORKDIR /app +COPY --from=builder /app/playground-server . +COPY frontend ./frontend + +EXPOSE 8080 + +CMD ["./playground-server"] diff --git a/playground/backend/main.go b/playground/backend/main.go new file mode 100644 index 0000000..55d5300 --- /dev/null +++ b/playground/backend/main.go @@ -0,0 +1,240 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const ( + compileTimeout = 10 * time.Second + runTimeout = 5 * time.Second + maxOutputSize = 64 * 1024 // 64KB + maxCodeSize = 64 * 1024 // 64KB +) + +type CompileRequest struct { + Code string `json:"code"` +} + +type CompileResponse struct { + Output string `json:"output"` + IsError bool `json:"isError"` +} + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + mux := http.NewServeMux() + mux.HandleFunc("/compile", handleCompile) + mux.HandleFunc("/health", handleHealth) + mux.Handle("/", http.FileServer(http.Dir("./frontend"))) + + log.Printf("Bux Playground server starting on :%s", port) + log.Fatal(http.ListenAndServe(":"+port, cors(mux))) +} + +func cors(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == "OPTIONS" { + w.WriteHeader(http.StatusOK) + return + } + next.ServeHTTP(w, r) + }) +} + +func handleHealth(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} + +func handleCompile(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Read code from body + body, err := io.ReadAll(io.LimitReader(r.Body, maxCodeSize+1)) + if err != nil { + writeError(w, "Failed to read request body") + return + } + if len(body) > maxCodeSize { + writeError(w, "Code exceeds maximum size (64KB)") + return + } + + code := strings.TrimSpace(string(body)) + if code == "" { + writeError(w, "Empty code") + return + } + + // Create temp project directory + tmpDir, err := os.MkdirTemp("", "bux-playground-*") + if err != nil { + writeError(w, "Failed to create temp directory") + return + } + defer os.RemoveAll(tmpDir) + + // Write code to src/Main.bux + srcDir := filepath.Join(tmpDir, "src") + if err := os.MkdirAll(srcDir, 0755); err != nil { + writeError(w, "Failed to create src directory") + return + } + + mainFile := filepath.Join(srcDir, "Main.bux") + if err := os.WriteFile(mainFile, []byte(code), 0644); err != nil { + writeError(w, "Failed to write source file") + return + } + + // Write bux.toml + buxToml := `[Package] +Name = "playground" +Version = "0.1.0" +Type = "bin" + +[Build] +Output = "Bin" +` + if err := os.WriteFile(filepath.Join(tmpDir, "bux.toml"), []byte(buxToml), 0644); err != nil { + writeError(w, "Failed to write bux.toml") + return + } + + // Run buxc2 build inside Docker sandbox + output, isError := runInSandbox(tmpDir) + + // Truncate output if too large + if len(output) > maxOutputSize { + output = output[:maxOutputSize] + "\n... (output truncated)" + } + + resp := CompileResponse{ + Output: output, + IsError: isError, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} + +func runInSandbox(projectDir string) (string, bool) { + // Check if running inside Docker (sandbox container available) + sandboxImage := os.Getenv("SANDBOX_IMAGE") + if sandboxImage == "" { + sandboxImage = "bux-playground-sandbox" + } + + // Determine if we use Docker or local buxc2 + useDocker := os.Getenv("USE_DOCKER") != "0" + + var cmd *exec.Cmd + var ctx context.Context + var cancel context.CancelFunc + + if useDocker { + // Run compilation inside Docker container + ctx, cancel = context.WithTimeout(context.Background(), compileTimeout) + defer cancel() + + cmd = exec.CommandContext(ctx, "docker", "run", + "--rm", + "--network=none", + "--memory=128m", + "--memory-swap=128m", + "--cpus=1.0", + "--pids-limit=64", + "--read-only", + "--tmpfs", "/tmp:noexec,nosuid,size=50m", + "-v", projectDir+":/project:ro", + "-w", "/project", + sandboxImage, + "sh", "-c", + "buxc2 build 2>&1 && timeout 5s ./build/playground 2>&1 || true", + ) + } else { + // Local mode (for development) + ctx, cancel = context.WithTimeout(context.Background(), compileTimeout) + defer cancel() + + buxc2Path := os.Getenv("BUXC2_PATH") + if buxc2Path == "" { + buxc2Path = "buxc2" + } + + // First compile + compileCmd := exec.CommandContext(ctx, buxc2Path, "build") + compileCmd.Dir = projectDir + compileCmd.Env = append(os.Environ(), + "HOME=/tmp", + ) + + compileOut, compileErr := compileCmd.CombinedOutput() + output := string(compileOut) + if compileErr != nil { + return output, true + } + + // Then run the binary + runCtx, runCancel := context.WithTimeout(context.Background(), runTimeout) + defer runCancel() + + binPath := filepath.Join(projectDir, "build", "playground") + runCmd := exec.CommandContext(runCtx, binPath) + runCmd.Dir = projectDir + runOut, runErr := runCmd.CombinedOutput() + output += string(runOut) + if runErr != nil && runCtx.Err() != context.DeadlineExceeded { + output += "\nRuntime error: " + runErr.Error() + } + + return output, false + } + + // Run Docker command + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + + err := cmd.Run() + output := out.String() + + if ctx.Err() == context.DeadlineExceeded { + output += "\nTimeout: compilation or execution took too long" + return output, true + } + + if err != nil { + return output, true + } + + return output, false +} + +func writeError(w http.ResponseWriter, msg string) { + resp := CompileResponse{ + Output: msg, + IsError: true, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) +} diff --git a/playground/docker-compose.yml b/playground/docker-compose.yml new file mode 100644 index 0000000..64389ed --- /dev/null +++ b/playground/docker-compose.yml @@ -0,0 +1,27 @@ +version: '3.8' + +services: + backend: + build: + context: . + dockerfile: backend/Dockerfile + ports: + - "8080:8080" + environment: + - PORT=8080 + - USE_DOCKER=1 + - SANDBOX_IMAGE=bux-playground-sandbox + volumes: + - /var/run/docker.sock:/var/run/docker.sock + depends_on: + - sandbox + restart: unless-stopped + + # Sandbox image is built separately and used by backend + sandbox: + build: + context: .. + dockerfile: playground/sandbox/Dockerfile + image: bux-playground-sandbox + # This container is not run directly — backend spawns it per-request + command: ["echo", "sandbox image ready"] diff --git a/playground/frontend/index.html b/playground/frontend/index.html new file mode 100644 index 0000000..baababc --- /dev/null +++ b/playground/frontend/index.html @@ -0,0 +1,511 @@ + + + + + + Bux Playground + + + + +
+ + Try the Bux programming language in your browser +
+ +
+
+
+
+
+ + + +
+
+
+
+
Output
+
+
+ Ctrl+Enter to run + Ready +
+
+
+ + + + + diff --git a/playground/sandbox/Dockerfile b/playground/sandbox/Dockerfile new file mode 100644 index 0000000..fcf3590 --- /dev/null +++ b/playground/sandbox/Dockerfile @@ -0,0 +1,33 @@ +# Bux Playground Sandbox +# Minimal container with Bux compiler + C toolchain +FROM debian:bookworm-slim + +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + clang \ + libc6-dev \ + libssl-dev \ + make \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +# Create sandbox user (no root) +RUN useradd -m -s /bin/bash sandbox + +# Copy Bux compiler binary +COPY buxc2 /usr/local/bin/buxc2 +RUN chmod +x /usr/local/bin/buxc2 + +# Copy stdlib and runtime +COPY lib /usr/local/share/bux/lib +COPY rt /usr/local/share/bux/rt + +# Set up working directory +WORKDIR /tmp/bux-run +RUN chown sandbox:sandbox /tmp/bux-run + +# Security: drop privileges, limit resources +USER sandbox + +# Default command (backend will override) +CMD ["/bin/bash"]