From 9502d1b1be4064db4487cbd2e625deaf3b928583 Mon Sep 17 00:00:00 2001 From: Dani Date: Thu, 21 Aug 2025 20:56:38 -0400 Subject: [PATCH] First Commit --- .dockerignore | 9 + .env.example | 0 .gitignore | 25 + Dockerfile | 44 ++ LICENSE | 24 + Makefile | 0 README.md | 24 + client/app.js | 183 +++++++ client/auth_callback.html | 44 ++ client/crypto.js | 36 ++ client/index.html | 69 +++ client/styles.css | 18 + cmd/shard/main.go | 80 +++ configs/shard.sample.yaml | 66 +++ ...94107cfd62510a2a656244756bf48359c44787923e | 24 + deploy/oci/cloud-init.yaml | 14 + deploy/oci/main.tf | 114 +++++ deploy/oci/variables.tf | 0 docker-compose.dev.yml | 18 + docker-compose.yml | 18 + go.mod | 5 + internal/api/http.go | 458 ++++++++++++++++++ internal/api/static.go | 54 +++ internal/config/config.go | 88 ++++ internal/federation/tls.go | 32 ++ internal/index/index.go | 123 +++++ internal/storage/fsstore.go | 83 ++++ scripts/build_armv6.sh | 8 + scripts/setup_buildx.sh | 6 + 29 files changed, 1667 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 client/app.js create mode 100644 client/auth_callback.html create mode 100644 client/crypto.js create mode 100644 client/index.html create mode 100644 client/styles.css create mode 100644 cmd/shard/main.go create mode 100644 configs/shard.sample.yaml create mode 100644 data/objects/d8/d5/d8d59d382c7e358037322494107cfd62510a2a656244756bf48359c44787923e create mode 100644 deploy/oci/cloud-init.yaml create mode 100644 deploy/oci/main.tf create mode 100644 deploy/oci/variables.tf create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 internal/api/http.go create mode 100644 internal/api/static.go create mode 100644 internal/config/config.go create mode 100644 internal/federation/tls.go create mode 100644 internal/index/index.go create mode 100644 internal/storage/fsstore.go create mode 100644 scripts/build_armv6.sh create mode 100644 scripts/setup_buildx.sh 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."