First Commit

This commit is contained in:
2025-08-21 20:56:38 -04:00
commit 9502d1b1be
29 changed files with 1667 additions and 0 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.git
.gitignore
**/node_modules
**/.DS_Store
bin
dist
*.log
*.tmp
data

0
.env.example Normal file
View File

25
.gitignore vendored Normal file
View File

@@ -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

44
Dockerfile Normal file
View File

@@ -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"]

24
LICENSE Normal file
View File

@@ -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 <https://unlicense.org>

0
Makefile Normal file
View File

24
README.md Normal file
View File

@@ -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. Its 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 <your repo> 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":"<sha256>",...}
curl -s http://localhost:8080/v1/object/<sha256> | head

183
client/app.js Normal file
View File

@@ -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 ? `<span class="badge">private</span>` : `<span class="badge">public</span>`;
div.innerHTML = `
<div class="meta"><code>${p.hash.slice(0,10)}…</code> · ${p.bytes} bytes · ${p.ts} ${badge}</div>
<div class="actions">
<button data-act="view">View</button>
<button data-act="save">Save blob</button>
<button data-act="delete">Delete (server)</button>
<button data-act="remove">Remove (local)</button>
</div>
<pre class="content" style="white-space:pre-wrap;margin-top:.5rem;"></pre>`;
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
}

44
client/auth_callback.html Normal file
View File

@@ -0,0 +1,44 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8"/>
<title>GreenCoast — Auth Callback</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<style>
body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; background:#0b1117; color:#e6edf3; display:flex; align-items:center; justify-content:center; height:100vh; }
.card { background:#0f1621; padding:1rem 1.2rem; border-radius:14px; max-width:560px; }
.muted{ color:#8b949e; }
</style>
</head>
<body>
<div class="card">
<h3>Signing you in…</h3>
<div id="msg" class="muted">Please wait.</div>
</div>
<script type="module">
const params = new URLSearchParams(location.search);
const code = params.get("code");
const origin = location.origin; // shard and client served together
const msg = (t)=>document.getElementById("msg").textContent = t;
async function run() {
if (!code) { msg("Missing 'code' parameter."); return; }
try {
const r = await fetch(origin + "/v1/auth/discord/callback?assent=1&code=" + encodeURIComponent(code));
if (!r.ok) { msg("Exchange failed: " + r.status); return; }
const j = await r.json();
// Save token into client config
const key = "gc_client_config_v1";
const cfg = JSON.parse(localStorage.getItem(key) || "{}");
cfg.bearer = j.token;
localStorage.setItem(key, JSON.stringify(cfg));
msg("Success. Redirecting…");
setTimeout(()=>location.href="/", 800);
} catch(e) {
msg("Error: " + (e?.message || e));
}
}
run();
</script>
</body>
</html>

36
client/crypto.js Normal file
View File

@@ -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" });
}

69
client/index.html Normal file
View File

@@ -0,0 +1,69 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>GreenCoast — Client</title>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<link rel="stylesheet" href="./styles.css"/>
</head>
<body>
<div class="container">
<h1>GreenCoast (Client)</h1>
<section class="card">
<h2>Connect</h2>
<div class="row">
<label>Shard URL</label>
<input id="shardUrl" placeholder="http://localhost:8080" />
</div>
<div class="row">
<label>Bearer (optional)</label>
<input id="bearer" placeholder="dev-local-token" />
</div>
<div class="row">
<label>Passphrase (private posts)</label>
<input id="passphrase" type="password" placeholder="••••••••" />
</div>
<div class="row">
<label>3rd-party SSO</label>
<div>
<button id="discordStart">Sign in with Discord</button>
<div class="muted" style="margin-top:.4rem;">
We use external providers only if you choose to. We cannot vouch for their security.
</div>
</div>
</div>
<button id="saveConn">Save</button>
<div id="health" class="muted"></div>
</section>
<section class="card">
<h2>Compose</h2>
<div class="row">
<label>Visibility</label>
<select id="visibility">
<option value="public">Public (plaintext)</option>
<option value="private">Private (E2EE via passphrase)</option>
</select>
</div>
<div class="row">
<label>Title</label>
<input id="title" placeholder="Optional title"/>
</div>
<div class="row">
<label>Body</label>
<textarea id="body" rows="6" placeholder="Write your post..."></textarea>
</div>
<button id="publish">Publish</button>
<div id="publishStatus" class="muted"></div>
</section>
<section class="card">
<h2>Posts (live index)</h2>
<div id="posts"></div>
</section>
</div>
<script type="module" src="./app.js"></script>
</body>
</html>

18
client/styles.css Normal file
View File

@@ -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; }

80
cmd/shard/main.go Normal file
View File

@@ -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))
}

66
configs/shard.sample.yaml Normal file
View File

@@ -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

View File

@@ -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. Its 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 <your repo> 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":"<sha256>",...}
curl -s http://localhost:8080/v1/object/<sha256> | head

View File

@@ -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."

114
deploy/oci/main.tf Normal file
View File

@@ -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
}

0
deploy/oci/variables.tf Normal file
View File

18
docker-compose.dev.yml Normal file
View File

@@ -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

18
docker-compose.yml Normal file
View File

@@ -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:

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module greencoast
go 1.22.0
require gopkg.in/yaml.v3 v3.0.1

458
internal/api/http.go Normal file
View File

@@ -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)
}

54
internal/api/static.go Normal file
View File

@@ -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
}
}

88
internal/config/config.go Normal file
View File

@@ -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
}

View File

@@ -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
}

123
internal/index/index.go Normal file
View File

@@ -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
}

View File

@@ -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
}

8
scripts/build_armv6.sh Normal file
View File

@@ -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

6
scripts/setup_buildx.sh Normal file
View File

@@ -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."