commit 9502d1b1be4064db4487cbd2e625deaf3b928583 Author: Dani Date: Thu Aug 21 20:56:38 2025 -0400 First Commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..13e8179 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.git +.gitignore +**/node_modules +**/.DS_Store +bin +dist +*.log +*.tmp +data diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5da447a --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Go build artifacts +/bin/ +/dist/ +/out/ +/coverage.txt +*.test + +# IDE/editor +.vscode/ +.idea/ +*.swp +*.swo + +# OS junk +.DS_Store +Thumbs.db + +# Docker +data/ +*.log +*.tmp + +# Env/config overrides +shard.yaml +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3bc8a2f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,44 @@ +# syntax=docker/dockerfile:1.7 + +######################## +# builder +######################## +FROM --platform=$BUILDPLATFORM golang:1.22-bookworm AS build +ARG TARGETOS +ARG TARGETARCH +ARG TARGETVARIANT + +WORKDIR /src + +# 1) Copy ONLY go.mod first and materialize go.sum inside the image +COPY go.mod ./ +# Produce go.sum (and module cache) before copying the rest +RUN --mount=type=cache,target=/root/.cache/go-build \ + go mod download && go mod verify + +# 2) Now copy the rest of the source +COPY . . + +# (Optional but nice) ensure module graph is tidy (updates go.sum if needed) +RUN --mount=type=cache,target=/root/.cache/go-build \ + go mod tidy + +# 3) Build (with ARM variant handling) +ENV CGO_ENABLED=0 +RUN --mount=type=cache,target=/root/.cache/go-build \ + if [ "${TARGETARCH}${TARGETVARIANT}" = "armv6" ]; then export GOARM=6; fi && \ + if [ "${TARGETARCH}${TARGETVARIANT}" = "armv7" ]; then export GOARM=7; fi && \ + GOOS=$TARGETOS GOARCH=$TARGETARCH go build -trimpath -ldflags="-s -w" \ + -o /out/greencoast-shard ./cmd/shard + +######################## +# runtime +######################## +FROM gcr.io/distroless/base-debian12:nonroot +WORKDIR /app +COPY --from=build /out/greencoast-shard /app/greencoast-shard +COPY configs/shard.sample.yaml /app/shard.yaml +VOLUME ["/var/lib/greencoast"] +EXPOSE 8080 8081 +USER nonroot:nonroot +ENTRYPOINT ["/app/greencoast-shard","--config","/app/shard.yaml"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fdddb29 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec4fd28 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# GreenCoast — Privacy-First, Shardable Social (Dockerized) + +**Goal:** A BlueSky-like experience with **shards**, **zero-trust**, **no data collection**, **E2EE**, and easy self-hosting — from x86_64 down to **Raspberry Pi Zero**. +License: **The Unlicense** (public-domain equivalent). + +This repo contains a minimal, working **shard**: an append-only object API with zero-data-collection defaults. It’s structured to evolve into full federation, E2EE, and client apps, while keeping Pi Zero as a supported host. + +--- + +## Quick Start (Laptop / Dev) + +**Requirements:** Docker + Compose v2 + +```bash +git clone greencoast +cd greencoast +cp .env.example .env +docker compose -f docker-compose.dev.yml up --build +# Health: +curl -s http://localhost:8080/healthz +# Put an object (dev mode allows unauthenticated PUT/GET): +curl -s -X PUT --data-binary @README.md http://localhost:8080/v1/object +# -> {"ok":true,"hash":"",...} +curl -s http://localhost:8080/v1/object/ | head diff --git a/client/app.js b/client/app.js new file mode 100644 index 0000000..326bf3c --- /dev/null +++ b/client/app.js @@ -0,0 +1,183 @@ +import { encryptString, decryptToString, toBlob } from "./crypto.js"; + +const els = { + shardUrl: document.getElementById("shardUrl"), + bearer: document.getElementById("bearer"), + passphrase: document.getElementById("passphrase"), + saveConn: document.getElementById("saveConn"), + health: document.getElementById("health"), + visibility: document.getElementById("visibility"), + title: document.getElementById("title"), + body: document.getElementById("body"), + publish: document.getElementById("publish"), + publishStatus: document.getElementById("publishStatus"), + posts: document.getElementById("posts"), + discordStart: document.getElementById("discordStart"), +}; + +const LS_KEY = "gc_client_config_v1"; +const POSTS_KEY = "gc_posts_index_v1"; + +const cfg = loadConfig(); applyConfig(); checkHealth(); syncIndex(); sse(); + +els.saveConn.onclick = async () => { + const c = { url: norm(els.shardUrl.value), bearer: els.bearer.value.trim(), passphrase: els.passphrase.value }; + saveConfig(c); await checkHealth(); await syncIndex(); sse(true); +}; + +els.publish.onclick = publish; +els.discordStart.onclick = discordStart; + +function loadConfig(){ try { return JSON.parse(localStorage.getItem(LS_KEY)) ?? {}; } catch { return {}; } } +function saveConfig(c){ localStorage.setItem(LS_KEY, JSON.stringify(c)); Object.assign(cfg, c); } +function getPosts(){ try { return JSON.parse(localStorage.getItem(POSTS_KEY)) ?? []; } catch { return []; } } +function setPosts(v){ localStorage.setItem(POSTS_KEY, JSON.stringify(v)); renderPosts(); } +function norm(u){ return (u||"").replace(/\/+$/,""); } +function applyConfig(){ els.shardUrl.value = cfg.url ?? location.origin; els.bearer.value = cfg.bearer ?? ""; els.passphrase.value = cfg.passphrase ?? ""; } + +async function checkHealth() { + if (!cfg.url) return; els.health.textContent = "Checking…"; + try { const r = await fetch(cfg.url + "/healthz"); els.health.textContent = r.ok ? "Connected ✔" : `Error: ${r.status}`; } + catch { els.health.textContent = "Not reachable"; } +} + +async function publish() { + if (!cfg.url) return msg("Set shard URL first.", true); + const title = els.title.value.trim(); const body = els.body.value; const vis = els.visibility.value; + try { + let blob, enc=false; + if (vis === "private") { + if (!cfg.passphrase) return msg("Set a passphrase for private posts.", true); + const payload = await encryptString(JSON.stringify({ title, body }), cfg.passphrase); + blob = toBlob(payload); enc=true; + } else { blob = toBlob(JSON.stringify({ title, body })); } + const headers = { "Content-Type":"application/octet-stream" }; + if (cfg.bearer) headers["Authorization"] = "Bearer " + cfg.bearer; + if (enc) headers["X-GC-Private"] = "1"; + const r = await fetch(cfg.url + "/v1/object", { method:"PUT", headers, body: blob }); + if (!r.ok) throw new Error(await r.text()); + const j = await r.json(); + const posts = getPosts(); + posts.unshift({ hash:j.hash, title: title || "(untitled)", bytes:j.bytes, ts:j.stored_at, enc }); + setPosts(posts); + els.body.value = ""; msg(`Published ${enc?"private":"public"} post. Hash: ${j.hash}`); + } catch(e){ msg("Publish failed: " + (e?.message||e), true); } +} + +function msg(t, err=false){ els.publishStatus.textContent=t; els.publishStatus.style.color = err ? "#ff6b6b" : "#8b949e"; } + +async function syncIndex() { + if (!cfg.url) return; + try { + const headers = {}; if (cfg.bearer) headers["Authorization"] = "Bearer " + cfg.bearer; + const r = await fetch(cfg.url + "/v1/index", { headers }); + if (!r.ok) throw new Error("index fetch failed"); + const entries = await r.json(); + setPosts(entries.map(e => ({ hash:e.hash, title:"(title unknown — fetch)", bytes:e.bytes, ts:e.stored_at, enc:e.private }))); + } catch(e){ console.warn("index sync failed", e); } +} + +let sseCtrl; +function sse(restart=false){ + if (!cfg.url) return; + if (sseCtrl) { sseCtrl.abort(); sseCtrl = undefined; } + sseCtrl = new AbortController(); + const url = cfg.url + "/v1/index/stream"; + const headers = {}; if (cfg.bearer) headers["Authorization"] = "Bearer " + cfg.bearer; + fetch(url, { headers, signal: sseCtrl.signal }).then(async resp => { + if (!resp.ok) return; + const reader = resp.body.getReader(); const decoder = new TextDecoder(); + let buf = ""; + while (true) { + const { value, done } = await reader.read(); if (done) break; + buf += decoder.decode(value, { stream:true }); + let idx; + while ((idx = buf.indexOf("\n\n")) >= 0) { + const chunk = buf.slice(0, idx); buf = buf.slice(idx+2); + if (chunk.startsWith("data: ")) { + try { + const ev = JSON.parse(chunk.slice(6)); + if (ev.event === "put") { + const e = ev.data; + const posts = getPosts(); + if (!posts.find(p => p.hash === e.hash)) { + posts.unshift({ hash:e.hash, title:"(title unknown — fetch)", bytes:e.bytes, ts:e.stored_at, enc:e.private }); + setPosts(posts); + } + } else if (ev.event === "delete") { + const h = ev.data.hash; setPosts(getPosts().filter(p => p.hash !== h)); + } + } catch {} + } + } + } + }).catch(()=>{}); +} + +function renderPosts() { + const posts = getPosts(); els.posts.innerHTML = ""; + for (const p of posts) { + const div = document.createElement("div"); div.className = "post"; + const badge = p.enc ? `private` : `public`; + div.innerHTML = ` +
${p.hash.slice(0,10)}… · ${p.bytes} bytes · ${p.ts} ${badge}
+
+ + + + +
+
`;
+    const pre = div.querySelector(".content");
+    div.querySelector('[data-act="view"]').onclick = () => viewPost(p, pre);
+    div.querySelector('[data-act="save"]').onclick = () => saveBlob(p);
+    div.querySelector('[data-act="delete"]').onclick = () => delServer(p);
+    div.querySelector('[data-act="remove"]').onclick = () => { setPosts(getPosts().filter(x=>x.hash!==p.hash)); };
+    els.posts.appendChild(div);
+  }
+}
+
+async function viewPost(p, pre) {
+  pre.textContent = "Loading…";
+  try {
+    const headers = {}; if (cfg.bearer) headers["Authorization"] = "Bearer " + cfg.bearer;
+    const r = await fetch(cfg.url + "/v1/object/" + p.hash, { headers });
+    if (!r.ok) throw new Error("fetch failed " + r.status);
+    const buf = new Uint8Array(await r.arrayBuffer());
+    let text;
+    if (p.enc) {
+      if (!cfg.passphrase) throw new Error("passphrase required");
+      text = await decryptToString(buf, cfg.passphrase);
+    } else { text = new TextDecoder().decode(buf); }
+    try {
+      const j = JSON.parse(text);
+      pre.textContent = (j.title ? `# ${j.title}\n\n` : "") + (j.body ?? text);
+    } catch { pre.textContent = text; }
+  } catch (e) { pre.textContent = "Error: " + (e?.message || e); }
+}
+
+async function saveBlob(p) {
+  const headers = {}; if (cfg.bearer) headers["Authorization"] = "Bearer " + cfg.bearer;
+  const r = await fetch(cfg.url + "/v1/object/" + p.hash, { headers });
+  if (!r.ok) return alert("download failed " + r.status);
+  const b = await r.blob();
+  const a = document.createElement("a"); a.href = URL.createObjectURL(b);
+  a.download = p.hash + (p.enc ? ".gcenc" : ".json"); a.click(); URL.revokeObjectURL(a.href);
+}
+
+async function delServer(p) {
+  const headers = {}; if (cfg.bearer) headers["Authorization"] = "Bearer " + cfg.bearer;
+  if (!confirm("Delete blob from server by hash?")) return;
+  const r = await fetch(cfg.url + "/v1/object/" + p.hash, { method:"DELETE", headers });
+  if (!r.ok) return alert("delete failed " + r.status);
+  setPosts(getPosts().filter(x=>x.hash!==p.hash));
+}
+
+async function discordStart() {
+  if (!cfg.url) { alert("Set shard URL first."); return; }
+  // Require explicit assent for third-party auth
+  const r = await fetch(cfg.url + "/v1/auth/discord/start", { headers: { "X-GC-3P-Assent":"1" }});
+  if (!r.ok) { alert("Discord SSO not available"); return; }
+  const j = await r.json();
+  location.href = j.url; // redirect to Discord; after consent, it returns to /auth-callback.html
+}
diff --git a/client/auth_callback.html b/client/auth_callback.html
new file mode 100644
index 0000000..b2c861b
--- /dev/null
+++ b/client/auth_callback.html
@@ -0,0 +1,44 @@
+
+
+
+  
+  GreenCoast — Auth Callback
+  
+  
+
+
+  
+

Signing you in…

+
Please wait.
+
+ + + diff --git a/client/crypto.js b/client/crypto.js new file mode 100644 index 0000000..10f67c4 --- /dev/null +++ b/client/crypto.js @@ -0,0 +1,36 @@ +export async function deriveKey(passphrase, saltBytes) { + const enc = new TextEncoder(); + const keyMaterial = await crypto.subtle.importKey("raw", enc.encode(passphrase), { name: "PBKDF2" }, false, ["deriveKey"]); + return crypto.subtle.deriveKey( + { name: "PBKDF2", salt: saltBytes, iterations: 120_000, hash: "SHA-256" }, + keyMaterial, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"] + ); +} +export async function encryptString(plaintext, passphrase) { + const enc = new TextEncoder(); + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveKey(passphrase, salt); + const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, enc.encode(plaintext)); + const version = new Uint8Array([1]); + const out = new Uint8Array(1 + 16 + 12 + ct.byteLength); + out.set(version, 0); out.set(salt, 1); out.set(iv, 17); out.set(new Uint8Array(ct), 29); + return out; +} +export async function decryptToString(payload, passphrase) { + const dec = new TextDecoder(); + if (!(payload instanceof Uint8Array)) payload = new Uint8Array(payload); + if (payload.length < 29) throw new Error("ciphertext too short"); + if (payload[0] !== 1) throw new Error("unknown version"); + const salt = payload.slice(1, 17), iv = payload.slice(17, 29), ct = payload.slice(29); + const key = await deriveKey(passphrase, salt); + const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, ct); + return dec.decode(pt); +} +export function toBlob(data) { + if (data instanceof Uint8Array) return new Blob([data], { type: "application/octet-stream" }); + return new Blob([data], { type: "application/json;charset=utf-8" }); +} diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..35263d3 --- /dev/null +++ b/client/index.html @@ -0,0 +1,69 @@ + + + + + GreenCoast — Client + + + + +
+

GreenCoast (Client)

+ +
+

Connect

+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ We use external providers only if you choose to. We cannot vouch for their security. +
+
+
+ +
+
+ +
+

Compose

+
+ + +
+
+ + +
+
+ + +
+ +
+
+ +
+

Posts (live index)

+
+
+
+ + + + diff --git a/client/styles.css b/client/styles.css new file mode 100644 index 0000000..7ed865f --- /dev/null +++ b/client/styles.css @@ -0,0 +1,18 @@ +:root { --bg:#0b1117; --card:#0f1621; --fg:#e6edf3; --muted:#8b949e; --accent:#2ea043; } +* { box-sizing: border-box; } +body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial; background:var(--bg); color:var(--fg); } +.container { max-width: 900px; margin: 2rem auto; padding: 0 1rem; } +h1 { font-size: 1.5rem; margin-bottom: 1rem; } +.card { background: var(--card); border-radius: 14px; padding: 1rem; margin-bottom: 1rem; box-shadow: 0 8px 24px rgba(0,0,0,.3); } +h2 { margin-top: 0; font-size: 1.1rem; } +.row { display: grid; grid-template-columns: 160px 1fr; gap: .75rem; align-items: center; margin: .5rem 0; } +label { color: var(--muted); } +input, select, textarea { width: 100%; padding: .6rem .7rem; border-radius: 10px; border: 1px solid #233; background: #0b1520; color: var(--fg); } +button { background: var(--accent); color: #08130b; border: none; padding: .6rem .9rem; border-radius: 10px; cursor: pointer; font-weight: 700; } +button:hover { filter: brightness(1.05); } +.muted { color: var(--muted); margin-top: .5rem; font-size: .9rem; } +.post { border: 1px solid #1d2734; border-radius: 12px; padding: .75rem; margin: .5rem 0; background: #0c1824; } +.post .meta { font-size: .85rem; color: var(--muted); margin-bottom: .4rem; } +.post .actions { margin-top: .5rem; display:flex; gap:.5rem; } +code { background:#0a1320; padding:.15rem .35rem; border-radius:6px; } +.badge { font-size:.75rem; padding:.1rem .4rem; border-radius: 999px; background:#132235; color:#9fb7d0; margin-left:.5rem; } diff --git a/cmd/shard/main.go b/cmd/shard/main.go new file mode 100644 index 0000000..16da659 --- /dev/null +++ b/cmd/shard/main.go @@ -0,0 +1,80 @@ +package main + +import ( + "flag" + "log" + "path/filepath" + + "greencoast/internal/api" + "greencoast/internal/config" + "greencoast/internal/federation" + "greencoast/internal/index" + "greencoast/internal/storage" +) + +func main() { + cfgPath := flag.String("config", "shard.yaml", "path to config") + flag.Parse() + + cfg, err := config.Load(*cfgPath) + if err != nil { + log.Fatalf("config error: %v", err) + } + + store, err := storage.NewFSStore(cfg.Storage.Path, cfg.Storage.MaxObjectKB) + if err != nil { + log.Fatalf("storage error: %v", err) + } + + dataRoot := filepath.Dir(cfg.Storage.Path) + idx := index.New(dataRoot) + + srv := api.New( + store, idx, + cfg.Privacy.RetainTimestamps == "coarse", + cfg.Security.ZeroTrust, + api.AuthProviders{ + SigningSecretHex: cfg.Auth.SigningSecret, + Discord: api.DiscordProvider{ + Enabled: cfg.Auth.SSO.Discord.Enabled, + ClientID: cfg.Auth.SSO.Discord.ClientID, + ClientSecret: cfg.Auth.SSO.Discord.ClientSecret, + RedirectURI: cfg.Auth.SSO.Discord.RedirectURI, + }, + GoogleEnabled: cfg.Auth.SSO.Google.Enabled, + FacebookEnabled: cfg.Auth.SSO.Facebook.Enabled, + WebAuthnEnabled: cfg.Auth.TwoFactor.WebAuthnEnabled, + TOTPEnabled: cfg.Auth.TwoFactor.TOTPEnabled, + }, + ) + + // Serve the client if enabled + if cfg.UI.Enable { + srv.MountStatic(cfg.UI.Path, cfg.UI.BaseURL) + } + + // listeners + if cfg.Listen.HTTP != "" { + go func() { log.Fatal(srv.ListenHTTP(cfg.Listen.HTTP)) }() + } + if cfg.TLS.Enable && cfg.Listen.HTTPS != "" { + go func() { log.Fatal(srv.ListenHTTPS(cfg.Listen.HTTPS, cfg.TLS.CertFile, cfg.TLS.KeyFile)) }() + } + if cfg.Federation.MTLSEnable { + tlsCfg, err := federation.ServerTLSConfig(cfg.Federation.CertFile, cfg.Federation.KeyFile, cfg.Federation.ClientCAFile) + if err != nil { + log.Fatalf("federation tls config error: %v", err) + } + go func() { log.Fatal(srv.ListenMTLS(cfg.Federation.Listen, tlsCfg)) }() + } + + // foreground + if cfg.TLS.Enable && cfg.Listen.HTTPS != "" { + log.Fatal(srv.ListenHTTPS(cfg.Listen.HTTPS, cfg.TLS.CertFile, cfg.TLS.KeyFile)) + return + } + if cfg.Listen.HTTP == "" { + log.Fatal("no listeners configured (set listen.http or listen.https)") + } + log.Fatal(srv.ListenHTTP(cfg.Listen.HTTP)) +} diff --git a/configs/shard.sample.yaml b/configs/shard.sample.yaml new file mode 100644 index 0000000..a5f93b2 --- /dev/null +++ b/configs/shard.sample.yaml @@ -0,0 +1,66 @@ +shard_id: "gc-001" + +listen: + http: "0.0.0.0:8080" + https: "" # e.g., "0.0.0.0:8443" when tls.enable=true + ws: "0.0.0.0:8081" # reserved (not used) + +tls: + enable: false + cert_file: "/etc/greencoast/tls/cert.pem" + key_file: "/etc/greencoast/tls/key.pem" + +federation: + mtls_enable: false + listen: "0.0.0.0:9443" + cert_file: "/etc/greencoast/fed/cert.pem" + key_file: "/etc/greencoast/fed/key.pem" + client_ca_file: "/etc/greencoast/fed/clients_ca.pem" + +ui: + enable: true + path: "./client" + base_url: "/" + +storage: + backend: "fs" + path: "/var/lib/greencoast/objects" + max_object_kb: 128 + +security: + zero_trust: true + require_mtls_for_federation: true + accept_client_signed_tokens: true + log_level: "warn" + +privacy: + retain_ip: "no" + retain_user_agent: "no" + retain_timestamps: "coarse" + +auth: + signing_secret: "50A936BBA70A6F469260ABF2D86A425C07FA3228D1B24D2A9079708CE787F6B09C75C64AA26170B6B2580EC06F4C7C9F4268B2859F864D5925550FC1768E69F9E1A65B32A7A075DF5FF4992E05369362A1753ED5929B4FD48B1291CD2A281C7C54881BD377410EE8D1D210C47613B4CBA7A0E6055F66D4B9402BB871C224D4FE" # hex key for HMAC shard tokens + sso: + discord: + enabled: false + client_id: "" + client_secret: "" + redirect_uri: "http://localhost:8080/auth-callback.html" + google: + enabled: false + client_id: "" + client_secret: "" + redirect_uri: "" + facebook: + enabled: false + client_id: "" + client_secret: "" + redirect_uri: "" + two_factor: + webauthn_enabled: false + totp_enabled: false + +limits: + rate: + burst: 20 + per_minute: 120 diff --git a/data/objects/d8/d5/d8d59d382c7e358037322494107cfd62510a2a656244756bf48359c44787923e b/data/objects/d8/d5/d8d59d382c7e358037322494107cfd62510a2a656244756bf48359c44787923e new file mode 100644 index 0000000..ec4fd28 --- /dev/null +++ b/data/objects/d8/d5/d8d59d382c7e358037322494107cfd62510a2a656244756bf48359c44787923e @@ -0,0 +1,24 @@ +# GreenCoast — Privacy-First, Shardable Social (Dockerized) + +**Goal:** A BlueSky-like experience with **shards**, **zero-trust**, **no data collection**, **E2EE**, and easy self-hosting — from x86_64 down to **Raspberry Pi Zero**. +License: **The Unlicense** (public-domain equivalent). + +This repo contains a minimal, working **shard**: an append-only object API with zero-data-collection defaults. It’s structured to evolve into full federation, E2EE, and client apps, while keeping Pi Zero as a supported host. + +--- + +## Quick Start (Laptop / Dev) + +**Requirements:** Docker + Compose v2 + +```bash +git clone greencoast +cd greencoast +cp .env.example .env +docker compose -f docker-compose.dev.yml up --build +# Health: +curl -s http://localhost:8080/healthz +# Put an object (dev mode allows unauthenticated PUT/GET): +curl -s -X PUT --data-binary @README.md http://localhost:8080/v1/object +# -> {"ok":true,"hash":"",...} +curl -s http://localhost:8080/v1/object/ | head diff --git a/deploy/oci/cloud-init.yaml b/deploy/oci/cloud-init.yaml new file mode 100644 index 0000000..3522745 --- /dev/null +++ b/deploy/oci/cloud-init.yaml @@ -0,0 +1,14 @@ +#cloud-config +package_update: true +package_upgrade: false +runcmd: + - curl -fsSL https://get.docker.com | sh + - usermod -aG docker ubuntu || true + - mkdir -p /opt/greencoast + - apt-get update && apt-get install -y git ca-certificates + - git clone --depth=1 https://github.com/yourname/greencoast.git /opt/greencoast + - cd /opt/greencoast && docker compose pull || true + - cd /opt/greencoast && docker compose up -d + - ufw allow 8080/tcp || true + - ufw allow 8081/tcp || true +final_message: "GreenCoast shard bootstrapped on ports 8080/8081." diff --git a/deploy/oci/main.tf b/deploy/oci/main.tf new file mode 100644 index 0000000..db221c8 --- /dev/null +++ b/deploy/oci/main.tf @@ -0,0 +1,114 @@ +terraform { + required_providers { + oci = { + source = "oracle/oci" + version = "~> 6.0" + } + } + required_version = ">= 1.5.0" +} + +provider "oci" { + region = var.region +} + +data "oci_identity_availability_domain" "ad1" { + compartment_id = var.compartment_ocid + ad_number = 1 +} + +resource "oci_core_vcn" "gc" { + cidr_block = "10.42.0.0/16" + compartment_id = var.compartment_ocid + display_name = "gc-vcn" +} + +resource "oci_core_internet_gateway" "igw" { + compartment_id = var.compartment_ocid + vcn_id = oci_core_vcn.gc.id + display_name = "gc-igw" + enabled = true +} + +resource "oci_core_route_table" "rt" { + compartment_id = var.compartment_ocid + vcn_id = oci_core_vcn.gc.id + display_name = "gc-rt" + route_rules { + network_entity_id = oci_core_internet_gateway.igw.id + destination = "0.0.0.0/0" + destination_type = "CIDR_BLOCK" + } +} + +resource "oci_core_subnet" "subnet" { + cidr_block = "10.42.1.0/24" + compartment_id = var.compartment_ocid + vcn_id = oci_core_vcn.gc.id + display_name = "gc-subnet" + prohibit_public_ip_on_vnic = false + route_table_id = oci_core_route_table.rt.id + dns_label = "gcsubnet" +} + +resource "oci_core_security_list" "sl" { + compartment_id = var.compartment_ocid + vcn_id = oci_core_vcn.gc.id + display_name = "gc-sec" + + egress_security_rules { + destination = "0.0.0.0/0" + protocol = "all" + } + + ingress_security_rules { + protocol = "6" + source = "0.0.0.0/0" + tcp_options { min = 22, max = 22 } # SSH + } + + ingress_security_rules { + protocol = "6" + source = "0.0.0.0/0" + tcp_options { min = 8080, max = 8080 } # API + } + + ingress_security_rules { + protocol = "6" + source = "0.0.0.0/0" + tcp_options { min = 8081, max = 8081 } # WS + } +} + +resource "oci_core_instance" "vm" { + compartment_id = var.compartment_ocid + availability_domain = data.oci_identity_availability_domain.ad1.name + + shape = var.shape + + shape_config { + ocpus = var.ocpus + memory_in_gbs = var.memory_gb + } + + source_details { + source_type = "image" + source_id = var.image_ocid + } + + create_vnic_details { + subnet_id = oci_core_subnet.subnet.id + assign_public_ip = true + } + + metadata = { + user_data = filebase64("${path.module}/cloud-init.yaml") + ssh_authorized_keys = var.ssh_public_key + } + + display_name = "greencoast-shard" +} + +output "public_ip" { + value = oci_core_instance.vm.public_ip +} diff --git a/deploy/oci/variables.tf b/deploy/oci/variables.tf new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..47551f5 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,18 @@ +version: "3.9" +services: + shard: + image: greencoast/shard:dev + build: + context: . + container_name: greencoast-shard-dev + restart: unless-stopped + user: "0:0" # <-- run as root in dev + ports: + - "8080:8080" + - "8081:8081" + environment: + - GC_DEV_ALLOW_UNAUTH=true + - GC_DEV_BEARER=dev-local-token + volumes: + - ./data:/var/lib/greencoast # <-- bind-mount a host folder + - ./configs/shard.sample.yaml:/app/shard.yaml:ro diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d30c429 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,18 @@ +version: "3.9" +services: + shard: + image: greencoast/shard:stable + build: + context: . + container_name: greencoast-shard + restart: unless-stopped + ports: + - "8080:8080" + - "8081:8081" + environment: + - GC_DEV_ALLOW_UNAUTH=false # enforce auth path + volumes: + - gc_data:/var/lib/greencoast + - ./configs/shard.sample.yaml:/app/shard.yaml:ro +volumes: + gc_data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cd18744 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module greencoast + +go 1.22.0 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/internal/api/http.go b/internal/api/http.go new file mode 100644 index 0000000..e2d966a --- /dev/null +++ b/internal/api/http.go @@ -0,0 +1,458 @@ +package api + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "os" + "sort" + "strings" + "sync" + "time" + + "greencoast/internal/index" + "greencoast/internal/storage" +) + +// ----------- Auth Providers & config (SSO / 2FA stubs) ------------ + +type DiscordProvider struct { + Enabled bool + ClientID string + ClientSecret string + RedirectURI string +} + +type AuthProviders struct { + SigningSecretHex string + Discord DiscordProvider + GoogleEnabled bool // placeholder + FacebookEnabled bool // placeholder + WebAuthnEnabled bool // placeholder + TOTPEnabled bool // placeholder +} + +// ----------- SSE hub (live index) ------------ + +type sseEvent struct { + Event string `json:"event"` // "put" | "delete" + Data interface{} `json:"data"` +} + +type hub struct { + mu sync.Mutex + subs map[chan []byte]struct{} +} + +func newHub() *hub { return &hub{subs: make(map[chan []byte]struct{})} } + +func (h *hub) subscribe() (ch chan []byte, cancel func()) { + ch = make(chan []byte, 16) + h.mu.Lock(); h.subs[ch] = struct{}{}; h.mu.Unlock() + cancel = func() { h.mu.Lock(); if _, ok := h.subs[ch]; ok { delete(h.subs, ch); close(ch) }; h.mu.Unlock() } + return ch, cancel +} +func (h *hub) broadcast(ev sseEvent) { + b, _ := json.Marshal(ev) + line := append([]byte("data: "), b...) + line = append(line, '\n', '\n') + h.mu.Lock() + for ch := range h.subs { + select { case ch <- line: default: } + } + h.mu.Unlock() +} + +// ----------- Server ------------ + +type Server struct { + mux *http.ServeMux + store *storage.FSStore + idx *index.Index + coarseTS bool + zeroTrust bool + + signingSecret []byte + discord DiscordProvider + + devAllow bool + devToken string + + live *hub +} + +func New(store *storage.FSStore, idx *index.Index, coarseTimestamps bool, zeroTrust bool, auth AuthProviders) *Server { + devAllow := strings.ToLower(os.Getenv("GC_DEV_ALLOW_UNAUTH")) == "true" + devToken := os.Getenv("GC_DEV_BEARER") + if devToken == "" { devToken = "dev-local-token" } + + sec := make([]byte, 0) + if auth.SigningSecretHex != "" { + if b, err := hex.DecodeString(auth.SigningSecretHex); err == nil { sec = b } + } + + s := &Server{ + mux: http.NewServeMux(), + store: store, + idx: idx, + coarseTS: coarseTimestamps, + zeroTrust: zeroTrust, + signingSecret: sec, + discord: auth.Discord, + devAllow: devAllow, + devToken: devToken, + live: newHub(), + } + s.routes() + return s +} + +// ---------- middleware helpers (privacy, CORS, auth) ---------- + +func (s *Server) secureHeaders(w http.ResponseWriter) { + // Anti-fingerprinting posture: do not echo request details; set strict policies. + w.Header().Set("Referrer-Policy", "no-referrer") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=(), interest-cohort=(), browsing-topics=()") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set("Cross-Origin-Opener-Policy", "same-origin") + w.Header().Set("Cross-Origin-Resource-Policy", "same-site") + // CORS: allow simple cross-origin usage without credentials (we do not use cookies). + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-GC-Private, X-GC-3P-Assent") + w.Header().Set("Access-Control-Allow-Methods", "GET, PUT, DELETE, OPTIONS") + // No-store by default (content blobs may be large but not user-identifying) + w.Header().Set("Cache-Control", "no-store") + // HSTS is meaningful over HTTPS; harmless otherwise. + w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload") +} + +func (s *Server) with(w http.ResponseWriter, r *http.Request, handler func(http.ResponseWriter, *http.Request)) { + s.secureHeaders(w) + // Handle CORS preflight + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + handler(w, r) +} + +func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + s.secureHeaders(w) + if !s.zeroTrust { + next.ServeHTTP(w, r); return + } + authz := r.Header.Get("Authorization") + + // Dev bypass if explicitly enabled + if s.devAllow { + if authz == "" || authz == "Bearer "+s.devToken { + next.ServeHTTP(w, r); return + } + } + if !strings.HasPrefix(authz, "Bearer ") { + http.Error(w, "unauthorized", http.StatusUnauthorized); return + } + if len(s.signingSecret) == 0 { + // If no signing secret configured, accept presence only (dev posture). + next.ServeHTTP(w, r); return + } + token := strings.TrimPrefix(authz, "Bearer ") + if ok := s.verifyShardToken(token); !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized); return + } + next.ServeHTTP(w, r) + } +} + +// ---------- shard token (HMAC, short-lived) ---------- + +// Format: gc|prov|sub|expEpoch|hex(hmacSHA256(secret, prov+'|'+sub+'|'+exp)) +func (s *Server) signShardToken(provider, subject string, exp time.Time) (string, error) { + if len(s.signingSecret) == 0 { + return "", errors.New("signing disabled (missing auth.signing_secret)") + } + msg := provider + "|" + subject + "|" + fmt.Sprint(exp.Unix()) + mac := hmac.New(sha256.New, s.signingSecret) + _, _ = mac.Write([]byte(msg)) + sig := hex.EncodeToString(mac.Sum(nil)) + return "gc|" + msg + "|" + sig, nil +} +func (s *Server) verifyShardToken(tok string) bool { + parts := strings.Split(tok, "|") + if len(parts) != 5 || parts[0] != "gc" { return false } + prov, sub, expStr, sig := parts[1], parts[2], parts[3], parts[4] + msg := prov + "|" + sub + "|" + expStr + mac := hmac.New(sha256.New, s.signingSecret) + _, _ = mac.Write([]byte(msg)) + want := hex.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(want), []byte(sig)) { return false } + // expiry + secs, err := time.ParseDuration(expStr + "s") + if err != nil { + // expStr is epoch; parse manually + epoch, e2 := time.ParseDuration("0s"); _ = epoch; _ = e2 + } + // treat expStr as epoch seconds + var expUnix int64 + fmt.Sscan(expStr, &expUnix) + return time.Now().UTC().Unix() < expUnix +} + +// ---------- routes ---------- + +func (s *Server) routes() { + s.mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + s.with(w, r, func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) + }) + + // PUT object (opaque). Client may flag privacy in index via X-GC-Private: 1. + s.mux.HandleFunc("/v1/object", s.auth(func(w http.ResponseWriter, r *http.Request) { + s.with(w, r, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed); return + } + isPrivate := strings.TrimSpace(r.Header.Get("X-GC-Private")) == "1" + hash, n, err := s.store.Put(r.Body) + if err != nil { http.Error(w, err.Error(), http.StatusBadRequest); return } + ts := s.nowCoarse() + _ = s.idx.AppendPut(index.Entry{ + Hash: hash, Bytes: n, StoredAt: s.parseRFC3339(ts), Private: isPrivate, + }) + s.live.broadcast(sseEvent{Event: "put", Data: map[string]any{ + "hash": hash, "bytes": n, "stored_at": ts, "private": isPrivate, + }}) + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"ok":true,"hash":"%s","bytes":%d,"stored_at":"%s"}`, hash, n, ts) + }) + })) + + // GET/DELETE object by hash + s.mux.HandleFunc("/v1/object/", s.auth(func(w http.ResponseWriter, r *http.Request) { + s.with(w, r, func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + hash := strings.TrimPrefix(r.URL.Path, "/v1/object/") + p, err := s.store.Get(hash) + if err != nil { http.NotFound(w, r); return } + f, err := os.Open(p) + if err != nil { http.Error(w, "open error", http.StatusInternalServerError); return } + defer f.Close() + w.Header().Set("Content-Type", "application/octet-stream") + _, _ = io.Copy(w, f) + case http.MethodDelete: + hash := strings.TrimPrefix(r.URL.Path, "/v1/object/") + if err := s.store.Delete(hash); err != nil { http.NotFound(w, r); return } + _ = s.idx.AppendDelete(hash) + s.live.broadcast(sseEvent{Event: "delete", Data: map[string]any{"hash": hash}}) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true,"deleted":true}`)) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + }) + })) + + // Index snapshot + s.mux.HandleFunc("/v1/index", s.auth(func(w http.ResponseWriter, r *http.Request) { + s.with(w, r, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed); return } + entries, err := s.idx.Snapshot() + if err != nil { http.Error(w, err.Error(), 500); return } + sort.Slice(entries, func(i, j int) bool { return entries[i].StoredAt.After(entries[j].StoredAt) }) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(entries) + }) + })) + + // Index live (SSE) + s.mux.HandleFunc("/v1/index/stream", s.auth(func(w http.ResponseWriter, r *http.Request) { + s.secureHeaders(w) + flusher, ok := w.(http.Flusher) + if !ok { http.Error(w, "stream unsupported", http.StatusInternalServerError); return } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Connection", "keep-alive") + + ch, cancel := s.live.subscribe() + defer cancel() + + _, _ = w.Write([]byte(": ok\n\n")); flusher.Flush() + ticker := time.NewTicker(25 * time.Second); defer ticker.Stop() + notify := r.Context().Done() + for { + select { + case <-notify: return + case <-ticker.C: + _, _ = w.Write([]byte(": ping\n\n")); flusher.Flush() + case msg, ok := <-ch: + if !ok { return } + _, _ = w.Write(msg); flusher.Flush() + } + } + })) + + // GDPR policy + Third-party disclaimer + s.mux.HandleFunc("/v1/gdpr/policy", func(w http.ResponseWriter, r *http.Request) { + s.with(w, r, func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "collect_ip": false, + "collect_useragent": false, + "timestamp_policy": s.ternary(s.coarseTS, "coarse-hour", "exact"), + "stores_pii": false, + "erasure": "DELETE /v1/object/{hash}", + "portability": "GET /v1/object/{hash}", + "third_party_auth": "Using external SSO providers is optional. We cannot vouch for their security; proceed only if you trust the provider.", + }) + }) + }) + + // ---------- Discord SSO (first provider) ---------- + + // Start: returns authorization URL. Requires explicit assent. + s.mux.HandleFunc("/v1/auth/discord/start", func(w http.ResponseWriter, r *http.Request) { + s.with(w, r, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if !s.discord.Enabled { + http.Error(w, "discord SSO disabled", http.StatusNotImplemented); return + } + if !assented(r) { + http.Error(w, "third-party assent required (set header X-GC-3P-Assent: 1)", http.StatusPreconditionFailed); return + } + state := randHex(24) + url := "https://discord.com/api/oauth2/authorize" + + "?response_type=code" + + "&client_id=" + urlq(s.discord.ClientID) + + "&scope=" + urlq("identify") + + "&redirect_uri=" + urlq(s.discord.RedirectURI) + + "&prompt=consent" + + "&state=" + urlq(state) + _ = state // stateless; client returns same state to callback; you can verify in client + _ = json.NewEncoder(w).Encode(map[string]any{"url": url, "note": "We cannot vouch for external IdP security."}) + }) + }) + + // Callback: exchanges code for Discord access_token, fetches @me to get subject id + // then issues a short-lived shard token (HMAC). No data persisted. + s.mux.HandleFunc("/v1/auth/discord/callback", func(w http.ResponseWriter, r *http.Request) { + s.with(w, r, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if !s.discord.Enabled { + http.Error(w, "discord SSO disabled", http.StatusNotImplemented); return + } + if !assented(r) { + http.Error(w, "third-party assent required (set header X-GC-3P-Assent: 1)", http.StatusPreconditionFailed); return + } + code := r.URL.Query().Get("code") + if code == "" { http.Error(w, "missing code", 400); return } + + // Exchange code -> access_token + form := "client_id=" + urlq(s.discord.ClientID) + + "&client_secret=" + urlq(s.discord.ClientSecret) + + "&grant_type=authorization_code" + + "&code=" + urlq(code) + + "&redirect_uri=" + urlq(s.discord.RedirectURI) + req, _ := http.NewRequest(http.MethodPost, "https://discord.com/api/oauth2/token", strings.NewReader(form)) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + if err != nil { http.Error(w, "token exchange failed", 502); return } + defer resp.Body.Close() + var tok struct{ AccessToken, TokenType string `json:"access_token","token_type"` } + if err := json.NewDecoder(resp.Body).Decode(&tok); err != nil || tok.AccessToken == "" { + http.Error(w, "invalid token response", 502); return + } + + // Fetch user id (PII seen in transit only; not stored) + uReq, _ := http.NewRequest(http.MethodGet, "https://discord.com/api/users/@me", nil) + uReq.Header.Set("Authorization", tok.TokenType+" "+tok.AccessToken) + uResp, err := http.DefaultClient.Do(uReq) + if err != nil { http.Error(w, "userinfo failed", 502); return } + defer uResp.Body.Close() + var me struct{ ID string `json:"id"` } + if err := json.NewDecoder(uResp.Body).Decode(&me); err != nil || me.ID == "" { + http.Error(w, "userinfo parse failed", 502); return + } + + // Issue shard token + exp := time.Now().UTC().Add(30 * time.Minute) + gcTok, err := s.signShardToken("discord", me.ID, exp) + if err != nil { http.Error(w, err.Error(), 500); return } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "ok": true, + "token": gcTok, + "expires_at": exp.Format(time.RFC3339), + "disclaimer": "This token is issued after authenticating with a third-party provider (Discord). We cannot vouch for third-party security.", + }) + }) + }) +} + +// ---------- helpers ---------- + +func (s *Server) nowCoarse() string { + ts := time.Now().UTC() + if s.coarseTS { ts = ts.Truncate(time.Hour) } + return ts.Format(time.RFC3339) +} +func (s *Server) parseRFC3339(v string) time.Time { t, _ := time.Parse(time.RFC3339, v); return t } + +func (s *Server) ternary[T any](cond bool, a, b T) T { if cond { return a }; return b } + +func assented(r *http.Request) bool { + if r.Header.Get("X-GC-3P-Assent") == "1" { return true } + if r.URL.Query().Get("assent") == "1" { return true } + return false +} + +func randHex(n int) string { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + // very unlikely; fall back to timestamp bytes + ts := time.Now().UnixNano() + for i := 0; i < n; i++ { b[i] = byte(ts >> (8 * (i % 8))) } + } + return hex.EncodeToString(b) +} + +// randReader uses crypto/rand without importing directly to keep imports tidy here. +type randReader struct{} +func (randReader) Read(p []byte) (int, error) { return io.ReadFull(os.OpenFile("/dev/urandom", os.O_RDONLY, 0), p) } // fallback if needed (linux-only) + +// ----- listeners ----- + +func (s *Server) ListenHTTP(addr string) error { + log.Printf("http listening on %s", addr) + server := &http.Server{ Addr: addr, Handler: s.mux, ReadHeaderTimeout: 5 * time.Second } + ln, err := net.Listen("tcp", addr) + if err != nil { return err } + return server.Serve(ln) +} + +func (s *Server) ListenHTTPS(addr, certFile, keyFile string) error { + log.Printf("https listening on %s", addr) + server := &http.Server{ Addr: addr, Handler: s.mux, ReadHeaderTimeout: 5 * time.Second } + return server.ListenAndServeTLS(certFile, keyFile) +} + +func (s *Server) ListenMTLS(addr string, tlsCfg *tls.Config) error { + log.Printf("federation mTLS listening on %s", addr) + server := &http.Server{ Addr: addr, Handler: s.mux, ReadHeaderTimeout: 5 * time.Second, TLSConfig: tlsCfg } + ln, err := tls.Listen("tcp", addr, tlsCfg) + if err != nil { return err } + return server.Serve(ln) +} diff --git a/internal/api/static.go b/internal/api/static.go new file mode 100644 index 0000000..b7c9e47 --- /dev/null +++ b/internal/api/static.go @@ -0,0 +1,54 @@ +package api + +import ( + "net/http" + "os" + "path/filepath" + "strings" +) + +// MountStatic serves files from dir under baseURL. If baseURL == "/", it serves root. +// Directory listings are disabled. Unknown paths fall back to index.html (SPA). +func (s *Server) MountStatic(dir string, baseURL string) { + if dir == "" { + return + } + if baseURL == "" { + baseURL = "/" + } + fs := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.secureHeaders(w) + // normalize path inside dir + up := strings.TrimPrefix(r.URL.Path, baseURL) + if up == "" || strings.HasSuffix(r.URL.Path, "/") { + up = "index.html" + } + full := filepath.Join(dir, filepath.FromSlash(up)) + // prevent path escape + if !strings.HasPrefix(filepath.Clean(full), filepath.Clean(dir)) { + http.NotFound(w, r) + return + } + // serve if exists, else SPA fallback + if st, err := os.Stat(full); err == nil && !st.IsDir() { + http.ServeFile(w, r, full) + return + } + fallback := filepath.Join(dir, "index.html") + if _, err := os.Stat(fallback); err == nil { + http.ServeFile(w, r, fallback) + return + } + http.NotFound(w, r) + }) + // Root or subpath + if baseURL == "/" { + s.mux.Handle("/", fs) + } else { + if !strings.HasSuffix(baseURL, "/") { + baseURL += "/" + } + s.mux.Handle(baseURL, fs) + s.mux.Handle(baseURL+"", fs) // ensure exact mount works + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..d73dc56 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,88 @@ +package config + +import ( + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + ShardID string `yaml:"shard_id"` + Listen struct { + HTTP string `yaml:"http"` + HTTPS string `yaml:"https"` + WS string `yaml:"ws"` + } `yaml:"listen"` + TLS struct { + Enable bool `yaml:"enable"` + CertFile string `yaml:"cert_file"` + KeyFile string `yaml:"key_file"` + } `yaml:"tls"` + Federation struct { + MTLSEnable bool `yaml:"mtls_enable"` + Listen string `yaml:"listen"` + CertFile string `yaml:"cert_file"` + KeyFile string `yaml:"key_file"` + ClientCAFile string `yaml:"client_ca_file"` + } `yaml:"federation"` + UI struct { + Enable bool `yaml:"enable"` + Path string `yaml:"path"` + BaseURL string `yaml:"base_url"` + } `yaml:"ui"` + Storage struct { + Backend string `yaml:"backend"` + Path string `yaml:"path"` + MaxObjectKB int `yaml:"max_object_kb"` + } `yaml:"storage"` + Security struct { + ZeroTrust bool `yaml:"zero_trust"` + RequireMTLSForFederation bool `yaml:"require_mtls_for_federation"` + AcceptClientSignedTokens bool `yaml:"accept_client_signed_tokens"` + LogLevel string `yaml:"log_level"` + } `yaml:"security"` + Privacy struct { + RetainIP string `yaml:"retain_ip"` + RetainUserAgent string `yaml:"retain_user_agent"` + RetainTimestamps string `yaml:"retain_timestamps"` + } `yaml:"privacy"` + Auth struct { + SigningSecret string `yaml:"signing_secret"` + SSO struct { + Discord struct { + Enabled bool `yaml:"enabled"` + ClientID string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + RedirectURI string `yaml:"redirect_uri"` + } `yaml:"discord"` + Google struct { + Enabled bool `yaml:"enabled"` + ClientID string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + RedirectURI string `yaml:"redirect_uri"` + } `yaml:"google"` + Facebook struct { + Enabled bool `yaml:"enabled"` + ClientID string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + RedirectURI string `yaml:"redirect_uri"` + } `yaml:"facebook"` + } `yaml:"sso"` + TwoFactor struct { + WebAuthnEnabled bool `yaml:"webauthn_enabled"` + TOTPEnabled bool `yaml:"totp_enabled"` + } `yaml:"two_factor"` + } `yaml:"auth"` +} + +func Load(path string) (*Config, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var c Config + if err := yaml.Unmarshal(b, &c); err != nil { + return nil, err + } + return &c, nil +} diff --git a/internal/federation/tls.go b/internal/federation/tls.go new file mode 100644 index 0000000..22ef777 --- /dev/null +++ b/internal/federation/tls.go @@ -0,0 +1,32 @@ +package federation + +import ( + "crypto/tls" + "crypto/x509" + "os" +) + +func ServerTLSConfig(certFile, keyFile, clientCAFile string) (*tls.Config, error) { + // Load server cert + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, err + } + + // Load client CA for mTLS + caPEM, err := os.ReadFile(clientCAFile) + if err != nil { + return nil, err + } + clientCAs := x509.NewCertPool() + if ok := clientCAs.AppendCertsFromPEM(caPEM); !ok { + return nil, err + } + + return &tls.Config{ + MinVersion: tls.VersionTLS13, + Certificates: []tls.Certificate{cert}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: clientCAs, + }, nil +} diff --git a/internal/index/index.go b/internal/index/index.go new file mode 100644 index 0000000..f087722 --- /dev/null +++ b/internal/index/index.go @@ -0,0 +1,123 @@ +package index + +import ( + "bufio" + "encoding/json" + "os" + "path/filepath" + "sort" + "sync" + "time" +) + +type opType string + +const ( + OpPut opType = "put" + OpDel opType = "del" +) + +type record struct { + Op opType `json:"op"` + Hash string `json:"hash"` + Bytes int64 `json:"bytes,omitempty"` + StoredAt time.Time `json:"stored_at,omitempty"` + Private bool `json:"private,omitempty"` +} + +type Entry struct { + Hash string `json:"hash"` + Bytes int64 `json:"bytes"` + StoredAt time.Time `json:"stored_at"` + Private bool `json:"private"` +} + +type Index struct { + path string + mu sync.Mutex +} + +func New(baseDir string) *Index { + return &Index{path: filepath.Join(baseDir, "index.jsonl")} +} + +func (i *Index) AppendPut(e Entry) error { + i.mu.Lock() + defer i.mu.Unlock() + return appendRec(i.path, record{ + Op: OpPut, + Hash: e.Hash, + Bytes: e.Bytes, + StoredAt: e.StoredAt, + Private: e.Private, + }) +} + +func (i *Index) AppendDelete(hash string) error { + i.mu.Lock() + defer i.mu.Unlock() + return appendRec(i.path, record{Op: OpDel, Hash: hash}) +} + +func appendRec(path string, r record) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return err + } + defer f.Close() + enc := json.NewEncoder(f) + return enc.Encode(r) +} + +func (i *Index) Snapshot() ([]Entry, error) { + i.mu.Lock() + defer i.mu.Unlock() + + f, err := os.Open(i.path) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + defer f.Close() + + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + + type state struct { + Entry Entry + Deleted bool + } + m := make(map[string]state) + for sc.Scan() { + var rec record + if err := json.Unmarshal(sc.Bytes(), &rec); err != nil { + continue + } + switch rec.Op { + case OpPut: + m[rec.Hash] = state{Entry: Entry{ + Hash: rec.Hash, Bytes: rec.Bytes, StoredAt: rec.StoredAt, Private: rec.Private, + }} + case OpDel: + s := m[rec.Hash] + s.Deleted = true + m[rec.Hash] = s + } + } + if err := sc.Err(); err != nil { + return nil, err + } + var out []Entry + for _, s := range m { + if !s.Deleted && s.Entry.Hash != "" { + out = append(out, s.Entry) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].StoredAt.After(out[j].StoredAt) }) + return out, nil +} diff --git a/internal/storage/fsstore.go b/internal/storage/fsstore.go new file mode 100644 index 0000000..7ac0e42 --- /dev/null +++ b/internal/storage/fsstore.go @@ -0,0 +1,83 @@ +package config + +import ( + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + ShardID string `yaml:"shard_id"` + Listen struct { + HTTP string `yaml:"http"` + HTTPS string `yaml:"https"` + WS string `yaml:"ws"` + } `yaml:"listen"` + TLS struct { + Enable bool `yaml:"enable"` + CertFile string `yaml:"cert_file"` + KeyFile string `yaml:"key_file"` + } `yaml:"tls"` + Federation struct { + MTLSEnable bool `yaml:"mtls_enable"` + Listen string `yaml:"listen"` + CertFile string `yaml:"cert_file"` + KeyFile string `yaml:"key_file"` + ClientCAFile string `yaml:"client_ca_file"` + } `yaml:"federation"` + Storage struct { + Backend string `yaml:"backend"` + Path string `yaml:"path"` + MaxObjectKB int `yaml:"max_object_kb"` + } `yaml:"storage"` + Security struct { + ZeroTrust bool `yaml:"zero_trust"` + RequireMTLSForFederation bool `yaml:"require_mtls_for_federation"` + AcceptClientSignedTokens bool `yaml:"accept_client_signed_tokens"` + LogLevel string `yaml:"log_level"` + } `yaml:"security"` + Privacy struct { + RetainIP string `yaml:"retain_ip"` + RetainUserAgent string `yaml:"retain_user_agent"` + RetainTimestamps string `yaml:"retain_timestamps"` + } `yaml:"privacy"` + Auth struct { + SigningSecret string `yaml:"signing_secret"` + SSO struct { + Discord struct { + Enabled bool `yaml:"enabled"` + ClientID string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + RedirectURI string `yaml:"redirect_uri"` + } `yaml:"discord"` + Google struct { + Enabled bool `yaml:"enabled"` + ClientID string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + RedirectURI string `yaml:"redirect_uri"` + } `yaml:"google"` + Facebook struct { + Enabled bool `yaml:"enabled"` + ClientID string `yaml:"client_id"` + ClientSecret string `yaml:"client_secret"` + RedirectURI string `yaml:"redirect_uri"` + } `yaml:"facebook"` + } `yaml:"sso"` + TwoFactor struct { + WebAuthnEnabled bool `yaml:"webauthn_enabled"` + TOTPEnabled bool `yaml:"totp_enabled"` + } `yaml:"two_factor"` + } `yaml:"auth"` +} + +func Load(path string) (*Config, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var c Config + if err := yaml.Unmarshal(b, &c); err != nil { + return nil, err + } + return &c, nil +} diff --git a/scripts/build_armv6.sh b/scripts/build_armv6.sh new file mode 100644 index 0000000..1f71e4d --- /dev/null +++ b/scripts/build_armv6.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +export GOOS=linux +export GOARCH=arm +export GOARM=6 +export CGO_ENABLED=0 +mkdir -p bin +go build -trimpath -ldflags="-s -w" -o bin/greencoast-shard ./cmd/shard diff --git a/scripts/setup_buildx.sh b/scripts/setup_buildx.sh new file mode 100644 index 0000000..6afe1f1 --- /dev/null +++ b/scripts/setup_buildx.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail +docker buildx create --name greencoast --use || true +docker run --privileged --rm tonistiigi/binfmt --install all +docker buildx inspect --bootstrap +echo "Buildx ready."