Initial commit: vault1984 NOC dashboard + monitoring agent
Pulled from noc.vault1984.com:/home/johan/vault1984-dashboard/ - dashboard.go: NOC status dashboard (SQLite, incidents, telemetry, AI chat) - chat.go + chat-ws.go: agent chat server - agent/: lightweight monitoring agent (CPU/disk/mem → HQ) - DEPLOYMENT-HANDOFF.md: deployment notes
This commit is contained in:
commit
7e29cdac2b
|
|
@ -0,0 +1,105 @@
|
|||
# Vault1984 POP Deployment — Handoff for Hans
|
||||
|
||||
**From:** Johan / James
|
||||
**Date:** March 7, 2026
|
||||
**Status:** Binaries built, download endpoints added, ready for your rollout
|
||||
|
||||
---
|
||||
|
||||
## What's ready on HQ (noc.vault1984.com)
|
||||
|
||||
Two new binaries in `/home/johan/vault1984-dashboard/`:
|
||||
|
||||
| File | Arch | Size |
|
||||
|------|------|------|
|
||||
| `vault1984` | linux/amd64 | ~18MB |
|
||||
| `vault1984-arm64` | linux/arm64 | ~18MB |
|
||||
|
||||
Download endpoints added to the dashboard (need rebuild):
|
||||
- `http://185.218.204.47:8080/download/vault1984`
|
||||
- `http://185.218.204.47:8080/download/vault1984-arm64`
|
||||
|
||||
To activate: rebuild `dashboard-go` and restart the service.
|
||||
|
||||
## What's new in the binary
|
||||
|
||||
Built-in telemetry. When launched with these flags, the vault POSTs system + vault metrics to HQ every N seconds:
|
||||
|
||||
```
|
||||
--telemetry-freq=60
|
||||
--telemetry-host=http://185.218.204.47:8080/telemetry
|
||||
--telemetry-token=<your-choice>
|
||||
```
|
||||
|
||||
Also works via env vars: `TELEMETRY_FREQ`, `TELEMETRY_HOST`, `TELEMETRY_TOKEN`. Without flags, telemetry is off — no behavior change for self-hosters.
|
||||
|
||||
**Payload** (JSON POST):
|
||||
```json
|
||||
{
|
||||
"version": "0.1.0",
|
||||
"hostname": "virginia",
|
||||
"uptime_seconds": 3600,
|
||||
"timestamp": "2026-03-06T10:00:00Z",
|
||||
"system": {
|
||||
"os": "linux", "arch": "arm64", "cpus": 2,
|
||||
"cpu_percent": 12.5,
|
||||
"memory_total_mb": 1024, "memory_used_mb": 340,
|
||||
"disk_total_mb": 8000, "disk_used_mb": 1200,
|
||||
"load_1m": 0.3
|
||||
},
|
||||
"vaults": {
|
||||
"count": 0, "total_size_mb": 0, "total_entries": 0
|
||||
},
|
||||
"mode": "hosted"
|
||||
}
|
||||
```
|
||||
|
||||
## What needs doing
|
||||
|
||||
### 1. Telemetry inbox on the dashboard
|
||||
|
||||
The dashboard doesn't have a `/telemetry` handler yet. You'll want to add one that:
|
||||
- Accepts the JSON payload above
|
||||
- Stores it (SQLite, or just update the existing nodes table)
|
||||
- Feeds into the status page
|
||||
|
||||
This is your call on how to wire it in — you know the dashboard code best.
|
||||
|
||||
### 2. Wipe the status DB
|
||||
|
||||
Johan wants the status.db wiped clean and rebuilt with only the three live nodes:
|
||||
|
||||
| Node ID | Name | Region | IP |
|
||||
|---------|------|--------|----|
|
||||
| `hq-zurich` | HQ — Zürich | Hostkey / CH | 185.218.204.47 |
|
||||
| `virginia` | Virginia | **us-east-1** | ? |
|
||||
| `singapore` | Singapore | ap-southeast-1 | 47.129.4.217 |
|
||||
|
||||
**Important:** The current "virginia" POP is tagged `us-east-2` with IP `3.145.131.247` — that's **Ohio (Dublin)**. Johan does NOT want Ohio. Please confirm:
|
||||
- Was this already moved to us-east-1 (actual Virginia)?
|
||||
- If not, we need to spin down Ohio and deploy in us-east-1.
|
||||
|
||||
The planned nodes (london, frankfurt, tokyo, etc.) can stay in the seed data as "planned" but shouldn't be in the live status rotation until deployed.
|
||||
|
||||
### 3. Deploy vault1984 to the two AWS POPs
|
||||
|
||||
Each POP needs:
|
||||
- The vault1984 binary (arm64 for t4g.micro)
|
||||
- A systemd service with telemetry flags pointing to HQ
|
||||
- Port 1984 open
|
||||
- `DATA_DIR` for vault storage
|
||||
|
||||
You already have `deploy-pop.sh` and SSM access — adapt as you see fit. The vault1984 binary replaces nothing; it runs alongside the existing v1984-agent (or you can consolidate, since vault1984 now reports its own metrics).
|
||||
|
||||
### 4. Infrastructure overview (for clarity)
|
||||
|
||||
| Server | Role | Location |
|
||||
|--------|------|----------|
|
||||
| zurich.inou.com | Kuma, security checks, shared git | Hostkey Zürich |
|
||||
| noc.vault1984.com | Dashboard, status page, marketing site, HQ | Hostkey Zürich |
|
||||
| virginia POP | Vault1984 hosted node | AWS us-east-1 (confirm!) |
|
||||
| singapore POP | Vault1984 hosted node | AWS ap-southeast-1 |
|
||||
|
||||
---
|
||||
|
||||
Questions? Ping Johan or ask James in the next session.
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
package main
|
||||
|
||||
import "syscall"
|
||||
|
||||
func diskUsage(path string) float64 {
|
||||
var stat syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &stat); err != nil {
|
||||
return 0
|
||||
}
|
||||
total := stat.Blocks * uint64(stat.Bsize)
|
||||
free := stat.Bavail * uint64(stat.Bsize)
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(total-free) / float64(total)
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
module vault1984-agent
|
||||
|
||||
go 1.24.0
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Config — override via env vars
|
||||
var (
|
||||
nodeID = getenv("NODE_ID", "unknown")
|
||||
hqURL = getenv("HQ_URL", "http://185.218.204.47:8080")
|
||||
interval = 60 * time.Second
|
||||
)
|
||||
|
||||
func getenv(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// ---- /proc readers ----
|
||||
|
||||
func cpuPercent() float64 {
|
||||
// Read two samples 500ms apart
|
||||
s1 := readCPUStat()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
s2 := readCPUStat()
|
||||
|
||||
idle1 := s1[3]
|
||||
idle2 := s2[3]
|
||||
total1, total2 := sum(s1), sum(s2)
|
||||
totalDiff := total2 - total1
|
||||
idleDiff := idle2 - idle1
|
||||
if totalDiff == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(totalDiff-idleDiff) / float64(totalDiff)
|
||||
}
|
||||
|
||||
func readCPUStat() []uint64 {
|
||||
data, err := os.ReadFile("/proc/stat")
|
||||
if err != nil {
|
||||
return make([]uint64, 10)
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if !strings.HasPrefix(line, "cpu ") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)[1:] // skip "cpu"
|
||||
vals := make([]uint64, len(fields))
|
||||
for i, f := range fields {
|
||||
vals[i], _ = strconv.ParseUint(f, 10, 64)
|
||||
}
|
||||
return vals
|
||||
}
|
||||
return make([]uint64, 10)
|
||||
}
|
||||
|
||||
func sum(vals []uint64) uint64 {
|
||||
var t uint64
|
||||
for _, v := range vals {
|
||||
t += v
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func memPercent() float64 {
|
||||
data, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
kv := map[string]uint64{}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSuffix(parts[0], ":")
|
||||
val, _ := strconv.ParseUint(parts[1], 10, 64)
|
||||
kv[key] = val
|
||||
}
|
||||
total := kv["MemTotal"]
|
||||
avail := kv["MemAvailable"]
|
||||
if total == 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(total-avail) / float64(total)
|
||||
}
|
||||
|
||||
func diskPercent() float64 {
|
||||
// Use /proc/mounts + statfs-equivalent via df output isn't available
|
||||
// Read from /proc/diskstats isn't straightforward for percent
|
||||
// Use /sys/fs (fallback: read statvfs via syscall)
|
||||
// Simple approach: parse `df /` output via reading /proc/mounts
|
||||
// Actually the cleanest without CGo: read /proc/self/mountstats or use syscall.Statfs
|
||||
// We'll use a simple read of /proc/mounts and pick root
|
||||
// For a CGo-free approach, parse /proc/diskstats for read/write rates instead
|
||||
data, err := os.ReadFile("/proc/diskstats")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
// Just return a simple proxy: if disk stats exist, device is healthy
|
||||
_ = data
|
||||
// Real disk % requires syscall.Statfs — we'll do it inline
|
||||
return diskUsage("/")
|
||||
}
|
||||
|
||||
// ---- OTLP JSON builder ----
|
||||
|
||||
func buildOTLP(cpu, mem, disk float64) []byte {
|
||||
now := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"resourceMetrics": []interface{}{
|
||||
map[string]interface{}{
|
||||
"resource": map[string]interface{}{
|
||||
"attributes": []interface{}{
|
||||
attr("service.name", "vault1984-pop"),
|
||||
attr("node.id", nodeID),
|
||||
attr("host.name", hostname()),
|
||||
},
|
||||
},
|
||||
"scopeMetrics": []interface{}{
|
||||
map[string]interface{}{
|
||||
"scope": map[string]interface{}{
|
||||
"name": "vault1984-agent",
|
||||
"version": "0.1",
|
||||
},
|
||||
"metrics": []interface{}{
|
||||
gauge("system.cpu.utilization", cpu, now),
|
||||
gauge("system.memory.utilization", mem, now),
|
||||
gauge("system.disk.utilization", disk, now),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
return b
|
||||
}
|
||||
|
||||
func attr(k, v string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"key": k,
|
||||
"value": map[string]interface{}{"stringValue": v},
|
||||
}
|
||||
}
|
||||
|
||||
func gauge(name string, val float64, tsNano string) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"name": name,
|
||||
"gauge": map[string]interface{}{
|
||||
"dataPoints": []interface{}{
|
||||
map[string]interface{}{
|
||||
"timeUnixNano": tsNano,
|
||||
"asDouble": val,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func hostname() string {
|
||||
h, _ := os.Hostname()
|
||||
return h
|
||||
}
|
||||
|
||||
// ---- Push ----
|
||||
|
||||
func push(cpu, mem, disk float64) error {
|
||||
body := buildOTLP(cpu, mem, disk)
|
||||
|
||||
// Push OTLP to HQ
|
||||
resp, err := http.Post(hqURL+"/v1/metrics", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("OTLP push: %w", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("OTLP push status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
log.Printf("pushed: cpu=%.1f%% mem=%.1f%% disk=%.1f%%", cpu*100, mem*100, disk*100)
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
log.Printf("=== vault1984-agent node=%s hq=%s interval=%s ===", nodeID, hqURL, interval)
|
||||
|
||||
for {
|
||||
cpu := cpuPercent()
|
||||
mem := memPercent()
|
||||
disk := diskPercent()
|
||||
|
||||
if err := push(cpu, mem, disk); err != nil {
|
||||
log.Printf("push error: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(interval)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,533 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
const (
|
||||
listenAddr = "100.85.192.60:1985"
|
||||
gatewayURL = "ws://127.0.0.1:18789/"
|
||||
gatewayToken = "601267edaccf8cd3d6afe222c3ce63602e210ff1ecc9a268"
|
||||
sessionKey = "agent:main:main"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gateway connection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Gateway struct {
|
||||
mu sync.Mutex
|
||||
conn *websocket.Conn
|
||||
connected bool
|
||||
// Pending requests: reqId/runId → channel that receives gateway frames
|
||||
pending map[string]chan map[string]interface{}
|
||||
pendingMu sync.Mutex
|
||||
}
|
||||
|
||||
var gw = &Gateway{pending: make(map[string]chan map[string]interface{})}
|
||||
|
||||
func (g *Gateway) register(key string) chan map[string]interface{} {
|
||||
ch := make(chan map[string]interface{}, 128)
|
||||
g.pendingMu.Lock()
|
||||
g.pending[key] = ch
|
||||
g.pendingMu.Unlock()
|
||||
return ch
|
||||
}
|
||||
|
||||
func (g *Gateway) unregister(key string) {
|
||||
g.pendingMu.Lock()
|
||||
delete(g.pending, key)
|
||||
g.pendingMu.Unlock()
|
||||
}
|
||||
|
||||
func (g *Gateway) dispatch(key string, msg map[string]interface{}) bool {
|
||||
g.pendingMu.Lock()
|
||||
ch, ok := g.pending[key]
|
||||
g.pendingMu.Unlock()
|
||||
if ok {
|
||||
select {
|
||||
case ch <- msg:
|
||||
default: // drop if full
|
||||
}
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
func (g *Gateway) send(msg interface{}) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if g.conn == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
return g.conn.WriteMessage(websocket.TextMessage, mustJSON(msg))
|
||||
}
|
||||
|
||||
// connect dials the gateway, performs the challenge/connect handshake,
|
||||
// then enters a read loop that dispatches incoming frames.
|
||||
func (g *Gateway) connect() {
|
||||
for {
|
||||
conn, _, err := websocket.DefaultDialer.Dial(gatewayURL, nil)
|
||||
if err != nil {
|
||||
log.Printf("[gw] dial: %v", err)
|
||||
time.Sleep(3 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
// 1. Read challenge
|
||||
var challenge map[string]interface{}
|
||||
if err := conn.ReadJSON(&challenge); err != nil {
|
||||
log.Printf("[gw] challenge read: %v", err)
|
||||
conn.Close()
|
||||
time.Sleep(3 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
// 2. Send connect request
|
||||
connectReq := map[string]interface{}{
|
||||
"type": "req",
|
||||
"id": fmt.Sprintf("conn-%d", time.Now().UnixMilli()),
|
||||
"method": "connect",
|
||||
"params": map[string]interface{}{
|
||||
"minProtocol": 3,
|
||||
"maxProtocol": 3,
|
||||
"client": map[string]interface{}{
|
||||
"id": "vault1984-chat",
|
||||
"version": "2026.3.2",
|
||||
"platform": "linux",
|
||||
"mode": "cli",
|
||||
},
|
||||
"auth": map[string]string{"token": gatewayToken},
|
||||
"scopes": []string{"operator.admin"},
|
||||
},
|
||||
}
|
||||
conn.WriteJSON(connectReq)
|
||||
|
||||
// 3. Read connect response
|
||||
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
var resp map[string]interface{}
|
||||
if err := conn.ReadJSON(&resp); err != nil || resp["ok"] != true {
|
||||
log.Printf("[gw] handshake failed: %v resp=%v", err, resp)
|
||||
conn.Close()
|
||||
time.Sleep(3 * time.Second)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Println("[gw] connected")
|
||||
g.mu.Lock()
|
||||
g.conn = conn
|
||||
g.connected = true
|
||||
g.mu.Unlock()
|
||||
|
||||
// Keep-alive pinger
|
||||
go func() {
|
||||
for g.connected {
|
||||
time.Sleep(25 * time.Second)
|
||||
g.mu.Lock()
|
||||
if g.conn != nil {
|
||||
g.conn.WriteMessage(websocket.PingMessage, nil)
|
||||
}
|
||||
g.mu.Unlock()
|
||||
}
|
||||
}()
|
||||
|
||||
// 4. Read loop — route frames to waiting callers
|
||||
for {
|
||||
conn.SetReadDeadline(time.Now().Add(90 * time.Second))
|
||||
var frame map[string]interface{}
|
||||
if err := conn.ReadJSON(&frame); err != nil {
|
||||
log.Printf("[gw] read: %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
switch frame["type"] {
|
||||
case "res":
|
||||
// Response to a request — dispatch by id
|
||||
if id, _ := frame["id"].(string); id != "" {
|
||||
g.dispatch(id, frame)
|
||||
}
|
||||
|
||||
case "event":
|
||||
event, _ := frame["event"].(string)
|
||||
if event == "chat" {
|
||||
if p, ok := frame["payload"].(map[string]interface{}); ok {
|
||||
if runId, _ := p["runId"].(string); runId != "" {
|
||||
g.dispatch(runId, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ignore health, tick, presence, etc.
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnected — reset and retry
|
||||
g.mu.Lock()
|
||||
g.conn = nil
|
||||
g.connected = false
|
||||
g.mu.Unlock()
|
||||
log.Println("[gw] disconnected, reconnecting...")
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// Chat sends a message to the agent and collects the streamed response.
|
||||
func (g *Gateway) Chat(text string) (string, error) {
|
||||
reqId := fmt.Sprintf("req-%d", time.Now().UnixMilli())
|
||||
|
||||
// Register to receive the ack (keyed by reqId)
|
||||
ch := g.register(reqId)
|
||||
defer g.unregister(reqId)
|
||||
|
||||
// Send chat.send
|
||||
err := g.send(map[string]interface{}{
|
||||
"type": "req",
|
||||
"id": reqId,
|
||||
"method": "chat.send",
|
||||
"params": map[string]interface{}{
|
||||
"sessionKey": sessionKey,
|
||||
"message": text,
|
||||
"idempotencyKey": reqId,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
timeout := time.After(120 * time.Second)
|
||||
var buf strings.Builder
|
||||
runRegistered := false
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-timeout:
|
||||
if buf.Len() > 0 {
|
||||
return buf.String(), nil
|
||||
}
|
||||
return "", fmt.Errorf("timeout")
|
||||
|
||||
case msg := <-ch:
|
||||
// Handle ack response to chat.send
|
||||
if msg["type"] == "res" {
|
||||
if msg["ok"] != true {
|
||||
errObj, _ := msg["error"].(map[string]interface{})
|
||||
errMsg, _ := errObj["message"].(string)
|
||||
return "", fmt.Errorf("gateway: %s", errMsg)
|
||||
}
|
||||
// Extract runId and register for chat events
|
||||
if payload, ok := msg["payload"].(map[string]interface{}); ok {
|
||||
if runId, _ := payload["runId"].(string); runId != "" && !runRegistered {
|
||||
g.pendingMu.Lock()
|
||||
g.pending[runId] = ch // reuse same channel
|
||||
g.pendingMu.Unlock()
|
||||
defer g.unregister(runId)
|
||||
runRegistered = true
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle chat event (delta / final / error)
|
||||
state, _ := msg["state"].(string)
|
||||
|
||||
if state == "delta" {
|
||||
for _, text := range extractText(msg) {
|
||||
buf.WriteString(text)
|
||||
}
|
||||
}
|
||||
|
||||
if state == "final" {
|
||||
// If we got deltas, use them; otherwise use the final message
|
||||
if buf.Len() == 0 {
|
||||
for _, text := range extractText(msg) {
|
||||
buf.WriteString(text)
|
||||
}
|
||||
}
|
||||
result := strings.TrimSpace(buf.String())
|
||||
if result == "" {
|
||||
return "[no response]", nil
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
if state == "error" {
|
||||
errMsg, _ := msg["errorMessage"].(string)
|
||||
if buf.Len() > 0 {
|
||||
return buf.String(), nil
|
||||
}
|
||||
return "", fmt.Errorf("agent error: %s", errMsg)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// extractText pulls text parts from a chat event message payload.
|
||||
func extractText(evt map[string]interface{}) []string {
|
||||
msg, ok := evt["message"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
parts, ok := msg["content"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var texts []string
|
||||
for _, p := range parts {
|
||||
if m, ok := p.(map[string]interface{}); ok {
|
||||
if t, ok := m["text"].(string); ok {
|
||||
texts = append(texts, t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return texts
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat message store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type Message struct {
|
||||
ID int64 `json:"id"`
|
||||
Sender string `json:"sender"`
|
||||
SenderType string `json:"sender_type"`
|
||||
Content string `json:"content"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
var (
|
||||
messages []Message
|
||||
msgMu sync.RWMutex
|
||||
uiClients []*websocket.Conn
|
||||
uiMu sync.Mutex
|
||||
)
|
||||
|
||||
func addMessage(m Message) {
|
||||
msgMu.Lock()
|
||||
messages = append(messages, m)
|
||||
if len(messages) > 200 {
|
||||
messages = messages[len(messages)-200:]
|
||||
}
|
||||
msgMu.Unlock()
|
||||
broadcastUI(m)
|
||||
}
|
||||
|
||||
func broadcastUI(m Message) {
|
||||
data := mustJSON(m)
|
||||
uiMu.Lock()
|
||||
defer uiMu.Unlock()
|
||||
alive := make([]*websocket.Conn, 0, len(uiClients))
|
||||
for _, c := range uiClients {
|
||||
if err := c.WriteMessage(websocket.TextMessage, data); err == nil {
|
||||
alive = append(alive, c)
|
||||
}
|
||||
}
|
||||
uiClients = alive
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
var wsUpgrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }}
|
||||
|
||||
func handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := wsUpgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Add to broadcast list
|
||||
uiMu.Lock()
|
||||
uiClients = append(uiClients, conn)
|
||||
uiMu.Unlock()
|
||||
|
||||
// Read messages from the UI
|
||||
for {
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
var incoming struct {
|
||||
Content string `json:"content"`
|
||||
Sender string `json:"sender"`
|
||||
}
|
||||
json.Unmarshal(data, &incoming)
|
||||
if incoming.Content == "" {
|
||||
continue
|
||||
}
|
||||
if incoming.Sender == "" {
|
||||
incoming.Sender = "Johan"
|
||||
}
|
||||
|
||||
userMsg := Message{
|
||||
ID: time.Now().UnixMilli(),
|
||||
Sender: incoming.Sender,
|
||||
SenderType: "human",
|
||||
Content: incoming.Content,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
addMessage(userMsg)
|
||||
|
||||
// Get agent response
|
||||
go func(text string) {
|
||||
response, err := gw.Chat(text)
|
||||
if err != nil {
|
||||
response = fmt.Sprintf("[error: %v]", err)
|
||||
}
|
||||
addMessage(Message{
|
||||
ID: time.Now().UnixMilli(),
|
||||
Sender: "Hans",
|
||||
SenderType: "ai",
|
||||
Content: response,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
}(incoming.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func handleAPI(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "POST only", 405)
|
||||
return
|
||||
}
|
||||
var msg Message
|
||||
json.NewDecoder(r.Body).Decode(&msg)
|
||||
if msg.Content == "" {
|
||||
http.Error(w, "content required", 400)
|
||||
return
|
||||
}
|
||||
if msg.Sender == "" {
|
||||
msg.Sender = "Johan"
|
||||
}
|
||||
msg.SenderType = "human"
|
||||
msg.Timestamp = time.Now().UnixMilli()
|
||||
msg.ID = msg.Timestamp
|
||||
addMessage(msg)
|
||||
|
||||
go func() {
|
||||
response, err := gw.Chat(msg.Content)
|
||||
if err != nil {
|
||||
response = fmt.Sprintf("[error: %v]", err)
|
||||
}
|
||||
addMessage(Message{
|
||||
ID: time.Now().UnixMilli(),
|
||||
Sender: "Hans",
|
||||
SenderType: "ai",
|
||||
Content: response,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
})
|
||||
}()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]bool{"ok": true})
|
||||
}
|
||||
|
||||
func handleMessages(w http.ResponseWriter, r *http.Request) {
|
||||
msgMu.RLock()
|
||||
defer msgMu.RUnlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(messages)
|
||||
}
|
||||
|
||||
func handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"gateway": gw.connected,
|
||||
"session": sessionKey,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func main() {
|
||||
go gw.connect()
|
||||
|
||||
// Wait for gateway
|
||||
for !gw.connected {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
log.Printf("Vault1984 Chat ready — http://%s", listenAddr)
|
||||
|
||||
http.HandleFunc("/", serveHTML)
|
||||
http.HandleFunc("/ws", handleWS)
|
||||
http.HandleFunc("/api/send", handleAPI)
|
||||
http.HandleFunc("/api/messages", handleMessages)
|
||||
http.HandleFunc("/api/status", handleStatus)
|
||||
log.Fatal(http.ListenAndServe(listenAddr, nil))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func mustJSON(v interface{}) []byte {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Embedded UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func serveHTML(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`<!DOCTYPE html>
|
||||
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Vault1984 Chat</title>
|
||||
<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,sans-serif;background:#050505;color:#e0e0e0;height:100vh;display:flex;justify-content:center}
|
||||
.container{width:100%;max-width:640px;display:flex;flex-direction:column;height:100vh;border-left:1px solid #1a1a1a;border-right:1px solid #1a1a1a}
|
||||
header{padding:16px 20px;border-bottom:1px solid #1a1a1a}
|
||||
header h1{font-size:16px;color:#00ff88;letter-spacing:2px}
|
||||
#chat{flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:12px}
|
||||
.msg{max-width:85%;padding:10px 14px;border-radius:8px;font-size:14px;line-height:1.5;white-space:pre-wrap;word-wrap:break-word}
|
||||
.msg .who{font-size:11px;color:#666;margin-bottom:4px}
|
||||
.msg.human{align-self:flex-end;background:#2a1a1a;border-right:3px solid #ff6b6b}
|
||||
.msg.ai{align-self:flex-start;background:#1a2a1a;border-left:3px solid #00ff88}
|
||||
#bar{padding:16px 20px;border-top:1px solid #1a1a1a;display:flex;gap:10px}
|
||||
#bar input{flex:1;background:#0d0d0d;border:1px solid #333;color:#e0e0e0;padding:10px 14px;border-radius:8px;font-size:14px;outline:none}
|
||||
#bar input:focus{border-color:#00ff88}
|
||||
#bar button{background:#00ff88;color:#0a0a0a;border:none;padding:10px 20px;border-radius:8px;font-weight:700;font-size:14px;cursor:pointer}
|
||||
</style></head>
|
||||
<body><div class="container">
|
||||
<header><h1>🔒 VAULT1984 // CHAT</h1></header>
|
||||
<div id="chat"></div>
|
||||
<div id="bar"><input id="inp" placeholder="Type..." autofocus><button onclick="send()">SEND</button></div>
|
||||
<script>
|
||||
const chat=document.getElementById('chat'),inp=document.getElementById('inp');
|
||||
let ws,reconn;
|
||||
function connect(){
|
||||
ws=new WebSocket('ws://'+location.host+'/ws');
|
||||
ws.onmessage=e=>{const m=JSON.parse(e.data);show(m)};
|
||||
ws.onclose=()=>{clearTimeout(reconn);reconn=setTimeout(connect,2000)};
|
||||
ws.onerror=()=>ws.close();
|
||||
}
|
||||
function show(m){
|
||||
const d=document.createElement('div');
|
||||
d.className='msg '+(m.sender_type||'system');
|
||||
d.innerHTML='<div class="who">'+esc(m.sender)+'</div>'+esc(m.content);
|
||||
chat.appendChild(d);
|
||||
chat.scrollTop=chat.scrollHeight;
|
||||
}
|
||||
function esc(s){return(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||||
function send(){
|
||||
const v=inp.value.trim();if(!v||!ws||ws.readyState!==1)return;
|
||||
ws.send(JSON.stringify({content:v,sender:'Johan'}));
|
||||
inp.value='';
|
||||
}
|
||||
inp.addEventListener('keydown',e=>{if(e.key==='Enter')send()});
|
||||
connect();
|
||||
// Load history
|
||||
fetch('/api/messages').then(r=>r.json()).then(msgs=>{if(msgs)msgs.forEach(show)});
|
||||
</script>
|
||||
</div></body></html>`))
|
||||
}
|
||||
|
|
@ -0,0 +1,361 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
var upgrader = websocket.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // Allow all origins for now
|
||||
},
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
ID int64 `json:"id"`
|
||||
Sender string `json:"sender"`
|
||||
SenderType string `json:"sender_type"` // "ai", "human", "system"
|
||||
Content string `json:"content"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
conn *websocket.Conn
|
||||
sender string
|
||||
senderType string
|
||||
}
|
||||
|
||||
var (
|
||||
clients = make(map[*Client]bool)
|
||||
clientsMu sync.RWMutex
|
||||
messages []Message
|
||||
messagesMu sync.RWMutex
|
||||
maxMessages = 1000
|
||||
)
|
||||
|
||||
const (
|
||||
port = 1985
|
||||
tailscaleIP = "100.85.192.60" // Only accessible via Tailscale
|
||||
openClawURL = "http://127.0.0.1:18789/tools/invoke"
|
||||
openClawToken = "601267edaccf8cd3d6afe222c3ce63602e210ff1ecc9a268"
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
// HTTP endpoints
|
||||
http.HandleFunc("/", handleHTML)
|
||||
http.HandleFunc("/chat.js", handleJS)
|
||||
http.HandleFunc("/api/send", handleSend)
|
||||
http.HandleFunc("/api/messages", handleMessages)
|
||||
http.HandleFunc("/api/status", handleStatus)
|
||||
|
||||
// WebSocket
|
||||
http.HandleFunc("/ws", handleWS)
|
||||
|
||||
addr := fmt.Sprintf("100.85.192.60:%d", port) // Bind to Tailscale IP only
|
||||
fmt.Printf("Vault1984 Chat v0.2 (Go+WS) running on http://localhost:%d\n", port)
|
||||
log.Fatal(http.ListenAndServe(addr, nil))
|
||||
}
|
||||
|
||||
func handleHTML(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(htmlPage))
|
||||
}
|
||||
|
||||
func handleJS(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
w.Write([]byte(jsPage))
|
||||
}
|
||||
|
||||
func handleSend(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
http.Error(w, "Method not allowed", 405)
|
||||
return
|
||||
}
|
||||
|
||||
var msg Message
|
||||
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
||||
http.Error(w, "Invalid JSON", 400)
|
||||
return
|
||||
}
|
||||
|
||||
if msg.Sender == "" || msg.Content == "" {
|
||||
http.Error(w, "Missing sender or content", 400)
|
||||
return
|
||||
}
|
||||
|
||||
if msg.SenderType == "" {
|
||||
msg.SenderType = "ai"
|
||||
}
|
||||
|
||||
msg.Timestamp = time.Now().UnixMilli()
|
||||
msg.ID = msg.Timestamp
|
||||
|
||||
messagesMu.Lock()
|
||||
messages = append([]Message{msg}, messages...)
|
||||
if len(messages) > maxMessages {
|
||||
messages = messages[:maxMessages]
|
||||
}
|
||||
messagesMu.Unlock()
|
||||
|
||||
// Broadcast to all connected WS clients
|
||||
broadcast(msg)
|
||||
|
||||
// If it's a human message, respond via OpenClaw agent
|
||||
if msg.SenderType == "human" {
|
||||
log.Printf("Calling OpenClaw for message from %s: %s", msg.Sender, msg.Content)
|
||||
|
||||
// Call OpenClaw to get real response (synchronous - wait for it)
|
||||
response := callOpenClaw(msg.Content, msg.Sender)
|
||||
|
||||
// Add the response to messages
|
||||
responseMsg := Message{
|
||||
ID: time.Now().UnixMilli(),
|
||||
Sender: "Hans",
|
||||
SenderType: "ai",
|
||||
Content: response,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
messagesMu.Lock()
|
||||
messages = append([]Message{responseMsg}, messages...)
|
||||
if len(messages) > maxMessages {
|
||||
messages = messages[:maxMessages]
|
||||
}
|
||||
messagesMu.Unlock()
|
||||
broadcast(responseMsg)
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"success": true,
|
||||
"message": msg,
|
||||
})
|
||||
}
|
||||
|
||||
func handleMessages(w http.ResponseWriter, r *http.Request) {
|
||||
since := 0
|
||||
if s := r.URL.Query().Get("since"); s != "" {
|
||||
fmt.Sscanf(s, "%d", &since)
|
||||
}
|
||||
|
||||
messagesMu.RLock()
|
||||
var filtered []Message
|
||||
for _, m := range messages {
|
||||
if int64(since) < m.Timestamp {
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
}
|
||||
messagesMu.RUnlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(filtered)
|
||||
}
|
||||
|
||||
func handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
clientsMu.RLock()
|
||||
defer clientsMu.RUnlock()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"status": "online",
|
||||
"messages_count": len(messages),
|
||||
"connected_count": len(clients),
|
||||
"uptime": "N/A", // Could track startup time
|
||||
})
|
||||
}
|
||||
|
||||
func handleWS(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Println("WS upgrade error:", err)
|
||||
return
|
||||
}
|
||||
|
||||
client := &Client{conn: conn, sender: "unknown", senderType: "human"}
|
||||
clientsMu.Lock()
|
||||
clients[client] = true
|
||||
clientsMu.Unlock()
|
||||
|
||||
// Send recent messages to new client (last 20)
|
||||
messagesMu.RLock()
|
||||
recent := messages
|
||||
if len(recent) > 20 {
|
||||
recent = recent[:20]
|
||||
}
|
||||
messagesMu.RUnlock()
|
||||
|
||||
for _, m := range recent {
|
||||
client.sendJSON(map[string]interface{}{
|
||||
"type": "history",
|
||||
"messages": []Message{m},
|
||||
})
|
||||
}
|
||||
|
||||
// Handle incoming messages
|
||||
for {
|
||||
_, data, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
var msg map[string]interface{}
|
||||
if err := json.Unmarshal(data, &msg); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if msg["type"] == "register" {
|
||||
if s, ok := msg["sender"].(string); ok {
|
||||
client.sender = s
|
||||
}
|
||||
if st, ok := msg["sender_type"].(string); ok {
|
||||
client.senderType = st
|
||||
}
|
||||
client.sendJSON(map[string]string{"type": "registered", "sender": client.sender})
|
||||
}
|
||||
|
||||
if msg["type"] == "ping" {
|
||||
client.sendJSON(map[string]string{"type": "pong"})
|
||||
}
|
||||
|
||||
// Handle message sent via WS
|
||||
log.Printf("WS message received: %v", msg)
|
||||
if content, ok := msg["content"].(string); ok && content != "" {
|
||||
sender := client.sender
|
||||
senderType := client.senderType
|
||||
if s, ok := msg["sender"].(string); ok && s != "" {
|
||||
sender = s
|
||||
}
|
||||
if st, ok := msg["sender_type"].(string); ok && st != "" {
|
||||
senderType = st
|
||||
}
|
||||
|
||||
log.Printf("Saving message from %s: %s", sender, content)
|
||||
|
||||
// Save and broadcast the user's message
|
||||
msgObj := Message{
|
||||
ID: time.Now().UnixMilli(),
|
||||
Sender: sender,
|
||||
SenderType: senderType,
|
||||
Content: content,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
|
||||
messagesMu.Lock()
|
||||
messages = append([]Message{msgObj}, messages...)
|
||||
if len(messages) > maxMessages {
|
||||
messages = messages[:maxMessages]
|
||||
}
|
||||
messagesMu.Unlock()
|
||||
|
||||
broadcast(msgObj)
|
||||
|
||||
// If it's a human message, call OpenClaw and broadcast response
|
||||
if senderType == "human" {
|
||||
go func() {
|
||||
response := callOpenClaw(content, sender)
|
||||
responseMsg := Message{
|
||||
ID: time.Now().UnixMilli(),
|
||||
Sender: "Hans",
|
||||
SenderType: "ai",
|
||||
Content: response,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
}
|
||||
messagesMu.Lock()
|
||||
messages = append([]Message{responseMsg}, messages...)
|
||||
if len(messages) > maxMessages {
|
||||
messages = messages[:maxMessages]
|
||||
}
|
||||
messagesMu.Unlock()
|
||||
broadcast(responseMsg)
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clientsMu.Lock()
|
||||
delete(clients, client)
|
||||
clientsMu.Unlock()
|
||||
conn.Close()
|
||||
}
|
||||
|
||||
func (c *Client) sendJSON(v interface{}) {
|
||||
if err := c.conn.WriteJSON(v); err != nil {
|
||||
log.Println("WS send error:", err)
|
||||
}
|
||||
}
|
||||
|
||||
func broadcast(msg Message) {
|
||||
clientsMu.RLock()
|
||||
defer clientsMu.RUnlock()
|
||||
|
||||
for client := range clients {
|
||||
client.sendJSON(map[string]interface{}{
|
||||
"type": "message",
|
||||
"id": msg.ID,
|
||||
"sender": msg.Sender,
|
||||
"sender_type": msg.SenderType,
|
||||
"content": msg.Content,
|
||||
"timestamp": msg.Timestamp,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// callOpenClaw sends a message to OpenClaw and returns a real response
|
||||
func callOpenClaw(content, sender string) string {
|
||||
// Use local agent - will load model (~10s) but works
|
||||
// This is the fastest we can get with CLI without Gateway integration
|
||||
cmd := exec.Command("openclaw", "agent",
|
||||
"--message", content,
|
||||
"--session-id", fmt.Sprintf("chat-%d", time.Now().UnixMilli()),
|
||||
"--local",
|
||||
"--timeout", "15")
|
||||
|
||||
output, err := cmd.Output()
|
||||
|
||||
if err != nil {
|
||||
log.Printf("OpenClaw agent error: %v, output: %s", err, string(output))
|
||||
return "📩 Received: " + content
|
||||
}
|
||||
|
||||
// Extract the response from the output
|
||||
response := strings.TrimSpace(string(output))
|
||||
|
||||
// Clean up the response - take the first meaningful line(s)
|
||||
lines := strings.Split(response, "\n")
|
||||
var cleanResponse string
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && !strings.HasPrefix(line, "{") && !strings.HasPrefix(line, "[") {
|
||||
cleanResponse = line
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if cleanResponse == "" {
|
||||
cleanResponse = "📩 Received: " + content
|
||||
}
|
||||
|
||||
// Truncate if too long
|
||||
if len(cleanResponse) > 500 {
|
||||
cleanResponse = cleanResponse[:500] + "..."
|
||||
}
|
||||
|
||||
log.Printf("Agent response: %s", cleanResponse)
|
||||
return cleanResponse
|
||||
}
|
||||
|
||||
const htmlPage = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Vault1984 — AI Chat</title>\n <style>\n * { box-sizing: border-box; margin: 0; padding: 0; }\n body { \n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n background: #050505; \n color: #e0e0e0; \n min-height: 100vh;\n display: flex;\n justify-content: center;\n }\n .container {\n width: 100%;\n max-width: 600px;\n display: flex;\n flex-direction: column;\n border-left: 1px solid #1a1a1a;\n border-right: 1px solid #1a1a1a;\n }\n header {\n padding: 20px;\n border-bottom: 1px solid #1a1a1a;\n display: flex;\n justify-content: space-between;\n align-items: center;\n }\n h1 { font-size: 18px; color: #00ff88; letter-spacing: 2px; }\n .status { font-size: 12px; color: #666; }\n .status.online { color: #00ff88; }\n #chat { \n flex: 1; \n overflow-y: auto; \n padding: 20px;\n display: flex;\n flex-direction: column;\n gap: 16px;\n }\n .message {\n max-width: 90%;\n padding: 12px 16px;\n border-radius: 8px;\n animation: fadeIn 0.2s ease;\n }\n @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }\n /* Color-coded by sender */\n .message.hans { \n align-self: flex-start; \n background: #1a2a1a; \n border-left: 3px solid #00ff88;\n }\n .message.james { \n align-self: flex-start; \n background: #1a1a2a; \n border-left: 3px solid #6366f1;\n }\n .message.johan { \n align-self: flex-end; \n background: #2a1a1a; \n border-right: 3px solid #ff6b6b;\n }\n .message.human { \n align-self: flex-end; \n background: #2a1a1a; \n border-right: 3px solid #ff6b6b;\n }\n .message.ai { \n align-self: flex-start; \n background: #1a1a2a; \n border-left: 3px solid #6366f1;\n }\n .message.system { \n align-self: center; \n background: transparent; \n color: #666;\n font-size: 12px;\n border: none;\n text-align: center;\n }\n .sender { font-size: 11px; color: #888; margin-bottom: 4px; }\n .sender.hans { color: #00ff88; }\n .sender.james { color: #6366f1; }\n .sender.johan, .sender.human { color: #ff6b6b; }\n .content { font-size: 14px; line-height: 1.5; white-space: pre-wrap; word-wrap: break-word; }\n .content code { background: #222; padding: 2px 6px; border-radius: 4px; }\n .content pre { background: #111; padding: 10px; border-radius: 8px; overflow-x: auto; }\n .time { font-size: 10px; color: #444; margin-top: 4px; }\n #input-area {\n padding: 20px;\n border-top: 1px solid #1a1a1a;\n display: flex;\n gap: 12px;\n }\n input { \n flex: 1;\n background: #0d0d0d;\n border: 1px solid #333;\n color: #e0e0e0;\n padding: 12px 16px;\n font-family: inherit;\n font-size: 14px;\n border-radius: 8px;\n }\n input:focus { outline: none; border-color: #00ff88; }\n button {\n background: #00ff88;\n color: #0a0a0a;\n border: none;\n padding: 12px 24px;\n font-family: inherit;\n font-weight: bold;\n border-radius: 8px;\n cursor: pointer;\n transition: all 0.2s;\n }\n button:hover { transform: scale(1.02); box-shadow: 0 0 20px rgba(0,255,136,0.3); }\n </style>\n</head>\n<body>\n <div class=\"container\">\n <header>\n <h1>🔒 VAULT1984 // CHAT v0.3</h1>\n <div class=\"status online\" id=\"status\">● CONNECTED</div>\n </header>\n <div id=\"chat\"></div>\n <div id=\"input-area\">\n <input type=\"text\" id=\"msg\" placeholder=\"Type a message...\" autofocus>\n <button onclick=\"send()\">SEND</button>\n </div>\n </div>\n <script src=\"/chat.js\"></script>\n</body>\n</html>"
|
||||
|
||||
const jsPage = "let ws;\nlet reconnectAttempts = 0;\nconst maxReconnectAttempts = 10;\nconst reconnectDelay = 1000;\n\nfunction getSenderClass(sender, senderType) {\n if (!sender) return 'system';\n const s = sender.toLowerCase();\n if (s === 'johan') return 'johan';\n if (s === 'hans') return 'hans';\n if (s === 'james') return 'james';\n if (senderType === 'human') return 'human';\n return 'ai';\n}\n\nfunction connect() {\n ws = new WebSocket('ws://' + location.host + '/ws');\n \n ws.onopen = () => {\n document.getElementById('status').className = 'status online';\n document.getElementById('status').textContent = '● CONNECTED';\n reconnectAttempts = 0;\n ws.send(JSON.stringify({ type: 'register', sender: 'Johan', sender_type: 'human' }));\n };\n \n ws.onmessage = (e) => {\n const data = JSON.parse(e.data);\n if (data.type === 'history' && data.messages) {\n // Sort by timestamp (oldest first)\n data.messages.sort((a, b) => a.timestamp - b.timestamp);\n data.messages.forEach(addMessage);\n } else if (data.type === 'message') {\n addMessage(data);\n }\n };\n \n ws.onclose = () => {\n document.getElementById('status').className = 'status';\n document.getElementById('status').textContent = '○ DISCONNECTED';\n if (reconnectAttempts < maxReconnectAttempts) {\n reconnectAttempts++;\n setTimeout(connect, reconnectDelay * reconnectAttempts);\n }\n };\n}\n\nfunction addMessage(msg) {\n const div = document.createElement('div');\n const senderClass = getSenderClass(msg.sender, msg.sender_type);\n div.className = 'message ' + senderClass;\n \n let content = msg.content || '';\n content = content.replace(/`([^`]+)`/g, '<code>$1</code>');\n content = content.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');\n \n div.innerHTML = '<div class=\"sender ' + senderClass + '\">' + (msg.sender || 'System') + '</div><div class=\"content\">' + content + '</div><div class=\"time\">' + new Date(msg.timestamp).toLocaleString() + '</div>';\n document.getElementById('chat').appendChild(div);\n document.getElementById('chat').scrollTop = document.getElementById('chat').scrollHeight;\n}\n\nfunction send() {\n const input = document.getElementById('msg');\n const content = input.value.trim();\n if (!content || !ws || ws.readyState !== 1) return;\n ws.send(JSON.stringify({ sender: 'Johan', sender_type: 'human', content: content }));\n input.value = '';\n}\n\ndocument.getElementById('msg').addEventListener('keypress', (e) => { if (e.key === 'Enter') send(); });\nsetInterval(() => { if (ws && ws.readyState === 1) ws.send(JSON.stringify({ type: 'ping' })); }, 30000);\nconnect();"
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,18 @@
|
|||
module vault1984-chat
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
modernc.org/libc v1.67.6 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.46.1 // indirect
|
||||
)
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
|
||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
#!/bin/bash
|
||||
# vault1984 POP Deploy Script — canonical, idempotent
|
||||
# Usage: ./deploy-pop.sh <node-id> <region>
|
||||
# Example: ./deploy-pop.sh virginia us-east-1
|
||||
set -euo pipefail
|
||||
|
||||
NODE_ID="${1:?usage: deploy-pop.sh <node-id> <region>}"
|
||||
REGION="${2:?usage: deploy-pop.sh <node-id> <region>}"
|
||||
HQ_URL="http://185.218.204.47:8080"
|
||||
IAM_PROFILE="vault1984-ssm-profile"
|
||||
INSTANCE_TYPE="t4g.micro"
|
||||
|
||||
echo "=== vault1984 POP Deploy: $NODE_ID in $REGION ==="
|
||||
|
||||
# 1. Find latest AL2 arm64 AMI
|
||||
echo "[1/6] Finding AMI..."
|
||||
AMI_ID=$(aws --region "$REGION" ec2 describe-images \
|
||||
--owners amazon \
|
||||
--filters "Name=name,Values=amzn2-ami-kernel-5.10-hvm-*-arm64-gp2" "Name=state,Values=available" \
|
||||
--query "sort_by(Images, &CreationDate)[-1].ImageId" --output text)
|
||||
echo " AMI: $AMI_ID"
|
||||
|
||||
# 2. Get/create vault1984-pop security group (outbound only, NO inbound)
|
||||
echo "[2/6] Security group..."
|
||||
VPC_ID=$(aws --region "$REGION" ec2 describe-vpcs \
|
||||
--filters "Name=isDefault,Values=true" --query "Vpcs[0].VpcId" --output text)
|
||||
SUBNET_ID=$(aws --region "$REGION" ec2 describe-subnets \
|
||||
--filters "Name=defaultForAz,Values=true" --query "Subnets[0].SubnetId" --output text)
|
||||
|
||||
# Look up existing SG — treat empty or literal "None" as missing
|
||||
SG_ID=$(aws --region "$REGION" ec2 describe-security-groups \
|
||||
--filters "Name=group-name,Values=vault1984-pop" "Name=vpc-id,Values=$VPC_ID" \
|
||||
--query "SecurityGroups[0].GroupId" --output text 2>/dev/null || true)
|
||||
|
||||
if [[ -z "$SG_ID" || "$SG_ID" == "None" ]]; then
|
||||
echo " Creating vault1984-pop SG..."
|
||||
SG_ID=$(aws --region "$REGION" ec2 create-security-group \
|
||||
--group-name vault1984-pop \
|
||||
--description "Vault1984 POP - outbound only, no inbound" \
|
||||
--vpc-id "$VPC_ID" --query "GroupId" --output text)
|
||||
# Remove the default allow-all inbound rule AWS adds to new SGs
|
||||
aws --region "$REGION" ec2 revoke-security-group-ingress \
|
||||
--group-id "$SG_ID" --protocol -1 --cidr 0.0.0.0/0 2>/dev/null || true
|
||||
aws --region "$REGION" ec2 revoke-security-group-ingress \
|
||||
--group-id "$SG_ID" --protocol -1 --source-group "$SG_ID" 2>/dev/null || true
|
||||
echo " Created SG: $SG_ID"
|
||||
else
|
||||
echo " Existing SG: $SG_ID"
|
||||
fi
|
||||
|
||||
# Verify SG has no inbound rules (safety check)
|
||||
INBOUND=$(aws --region "$REGION" ec2 describe-security-groups \
|
||||
--group-ids "$SG_ID" --query "SecurityGroups[0].IpPermissions" --output text)
|
||||
if [[ -n "$INBOUND" ]]; then
|
||||
echo " WARNING: vault1984-pop SG has inbound rules — check manually:"
|
||||
echo " $INBOUND"
|
||||
fi
|
||||
|
||||
# 3. Launch instance
|
||||
echo "[3/6] Launching $INSTANCE_TYPE..."
|
||||
INSTANCE_ID=$(aws --region "$REGION" ec2 run-instances \
|
||||
--image-id "$AMI_ID" \
|
||||
--instance-type "$INSTANCE_TYPE" \
|
||||
--subnet-id "$SUBNET_ID" \
|
||||
--security-group-ids "$SG_ID" \
|
||||
--iam-instance-profile Name="$IAM_PROFILE" \
|
||||
--tag-specifications "ResourceType=instance,Tags=[{Key=Name,Value=vault1984-$NODE_ID},{Key=vault1984-node,Value=$NODE_ID}]" \
|
||||
--query "Instances[0].InstanceId" --output text)
|
||||
echo " Instance: $INSTANCE_ID"
|
||||
|
||||
# Verify SG assignment immediately after launch
|
||||
ASSIGNED_SG=$(aws --region "$REGION" ec2 describe-instances \
|
||||
--instance-ids "$INSTANCE_ID" \
|
||||
--query "Reservations[0].Instances[0].SecurityGroups[0].GroupName" --output text)
|
||||
if [[ "$ASSIGNED_SG" != "vault1984-pop" ]]; then
|
||||
echo "ERROR: Instance launched with wrong SG: $ASSIGNED_SG (expected vault1984-pop)"
|
||||
echo " Terminating instance to avoid misconfigured POP."
|
||||
aws --region "$REGION" ec2 terminate-instances --instance-ids "$INSTANCE_ID" >/dev/null
|
||||
exit 1
|
||||
fi
|
||||
echo " SG verified: $ASSIGNED_SG ✓"
|
||||
|
||||
# 4. Wait for SSM
|
||||
echo "[4/6] Waiting for SSM (~2-3 min)..."
|
||||
PUBLIC_IP=""
|
||||
for i in $(seq 1 30); do
|
||||
STATUS=$(aws --region "$REGION" ssm describe-instance-information \
|
||||
--filters "Key=InstanceIds,Values=$INSTANCE_ID" \
|
||||
--query "InstanceInformationList[0].PingStatus" --output text 2>/dev/null || echo "None")
|
||||
if [[ "$STATUS" == "Online" ]]; then
|
||||
PUBLIC_IP=$(aws --region "$REGION" ec2 describe-instances \
|
||||
--instance-ids "$INSTANCE_ID" \
|
||||
--query "Reservations[0].Instances[0].PublicIpAddress" --output text)
|
||||
echo " SSM online after ~${i}0s — IP: $PUBLIC_IP"
|
||||
break
|
||||
fi
|
||||
echo " ($i/30) $STATUS..."
|
||||
sleep 10
|
||||
done
|
||||
[[ "$STATUS" == "Online" ]] || { echo "ERROR: SSM never came online"; exit 1; }
|
||||
|
||||
# 5. Install vault1984 + apply hardening inline (same approach as update-pop.sh)
|
||||
echo "[5/6] Deploying vault1984 and hardening..."
|
||||
|
||||
CMD_ID=$(aws --region "$REGION" ssm send-command \
|
||||
--instance-ids "$INSTANCE_ID" \
|
||||
--document-name AWS-RunShellScript \
|
||||
--parameters commands="[
|
||||
\"set -e\",
|
||||
\"hostnamectl set-hostname $NODE_ID\",
|
||||
\"curl -sfo /usr/local/bin/vault1984 $HQ_URL/download/vault1984-arm64\",
|
||||
\"chmod +x /usr/local/bin/vault1984\",
|
||||
\"mkdir -p /var/lib/vault1984\",
|
||||
\"printf '[Unit]\\nDescription=Vault1984\\nAfter=network.target\\n\\n[Service]\\nEnvironment=NODE_ID=$NODE_ID\\nExecStart=/usr/local/bin/vault1984 --telemetry-freq=60 --telemetry-host=$HQ_URL/telemetry\\nRestart=always\\nRestartSec=10\\nWorkingDirectory=/var/lib/vault1984\\n\\n[Install]\\nWantedBy=multi-user.target\\n' > /etc/systemd/system/vault1984.service\",
|
||||
\"systemctl daemon-reload && systemctl enable vault1984 && systemctl start vault1984\",
|
||||
\"systemctl is-active vault1984 && echo 'vault1984: OK' || echo 'vault1984: FAILED'\",
|
||||
\"amazon-linux-extras install epel -y -q > /dev/null 2>&1 || true\",
|
||||
\"yum install -y -q fail2ban > /dev/null 2>&1 || true\",
|
||||
\"printf '[DEFAULT]\\nbantime = 86400\\nfindtime = 600\\nmaxretry = 3\\nignoreip = 127.0.0.1/8 ::1\\n\\n[sshd]\\nenabled = true\\nport = ssh\\nfilter = sshd\\nlogpath = /var/log/secure\\nmaxretry = 3\\nbantime = 86400\\n' > /etc/fail2ban/jail.local\",
|
||||
\"systemctl enable fail2ban && systemctl restart fail2ban\",
|
||||
\"sleep 5 && fail2ban-client status sshd\",
|
||||
\"for svc in postfix rpcbind sshd; do systemctl stop \$svc 2>/dev/null; systemctl disable \$svc 2>/dev/null; done\",
|
||||
\"yum install -y -q chrony > /dev/null 2>&1 && systemctl enable chronyd && systemctl start chronyd || true\",
|
||||
\"printf 'net.ipv4.tcp_syncookies=1\\nnet.ipv4.icmp_echo_ignore_broadcasts=1\\nnet.ipv4.conf.all.rp_filter=1\\n' > /etc/sysctl.d/99-vault1984.conf && sysctl --system -q\",
|
||||
\"systemctl enable firewalld && systemctl start firewalld\",
|
||||
\"firewall-cmd --permanent --remove-service=ssh 2>/dev/null || true\",
|
||||
\"firewall-cmd --permanent --add-port=1984/tcp && firewall-cmd --reload\",
|
||||
\"echo 'DEPLOY_COMPLETE'\"
|
||||
]" \
|
||||
--query "Command.CommandId" --output text)
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
R=$(aws --region "$REGION" ssm get-command-invocation \
|
||||
--command-id "$CMD_ID" --instance-id "$INSTANCE_ID" \
|
||||
--query "{S:Status,O:StandardOutputContent,E:StandardErrorContent}" --output json 2>/dev/null || echo '{"S":"Pending"}')
|
||||
S=$(echo "$R" | python3 -c "import json,sys; print(json.load(sys.stdin).get('S','?'))")
|
||||
if [[ "$S" == "Success" || "$S" == "Failed" || "$S" == "TimedOut" ]]; then
|
||||
echo "$R" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('O','')); e=d.get('E',''); e and print('STDERR:',e)"
|
||||
[[ "$S" == "Success" ]] || { echo "ERROR: deploy command $S"; exit 1; }
|
||||
break
|
||||
fi
|
||||
echo " ($i/30) $S..."
|
||||
sleep 10
|
||||
done
|
||||
|
||||
# 6. Final verification
|
||||
echo "[6/6] Verification..."
|
||||
VERIFY_ID=$(aws --region "$REGION" ssm send-command \
|
||||
--instance-ids "$INSTANCE_ID" \
|
||||
--document-name AWS-RunShellScript \
|
||||
--parameters commands="[
|
||||
\"echo 'hostname:' $(hostname)\",
|
||||
\"systemctl is-active vault1984 && echo 'vault1984: active' || echo 'vault1984: FAILED'\",
|
||||
\"systemctl is-active fail2ban && echo 'fail2ban: active' || echo 'fail2ban: FAILED'\",
|
||||
\"fail2ban-client status sshd 2>/dev/null && echo 'fail2ban-sshd: OK' || echo 'fail2ban-sshd: FAILED'\",
|
||||
\"firewall-cmd --list-ports 2>/dev/null | grep -q 1984 && echo 'firewall: OK' || echo 'firewall: FAILED'\",
|
||||
\"systemctl is-active rpcbind 2>/dev/null && echo 'rpcbind: WARNING still active' || echo 'rpcbind: disabled OK'\"
|
||||
]" \
|
||||
--query "Command.CommandId" --output text)
|
||||
sleep 10
|
||||
aws --region "$REGION" ssm get-command-invocation \
|
||||
--command-id "$VERIFY_ID" --instance-id "$INSTANCE_ID" \
|
||||
--query "StandardOutputContent" --output text
|
||||
|
||||
echo ""
|
||||
echo "=== Deploy complete ==="
|
||||
echo " Node: $NODE_ID"
|
||||
echo " Region: $REGION"
|
||||
echo " Instance: $INSTANCE_ID"
|
||||
echo " IP: $PUBLIC_IP"
|
||||
echo " SG: vault1984-pop ($SG_ID)"
|
||||
echo ""
|
||||
echo "Add to POPS in update-pop.sh:"
|
||||
echo " [\"$NODE_ID\"]=\"$REGION:$INSTANCE_ID\""
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
#!/bin/bash
|
||||
# Vault1984 POP Hardening Script — idempotent, run via SSM
|
||||
# Usage: bash harden-pop.sh
|
||||
# Can be re-run safely at any time to re-apply hardening.
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== Vault1984 POP Hardening ==="
|
||||
|
||||
# 1. Update system
|
||||
echo "[1/8] Updating system..."
|
||||
yum update -y -q
|
||||
|
||||
# 2. Install fail2ban (via EPEL)
|
||||
echo "[2/8] Installing fail2ban..."
|
||||
amazon-linux-extras install epel -y -q 2>/dev/null || true
|
||||
yum install -y -q fail2ban
|
||||
systemctl enable fail2ban
|
||||
|
||||
# 3. Configure fail2ban — sshd jail
|
||||
# NOTE: jail section must be [sshd] (lowercase), not [ssHD]
|
||||
echo "[3/8] Configuring fail2ban..."
|
||||
cat > /etc/fail2ban/jail.local << 'EOF'
|
||||
[DEFAULT]
|
||||
bantime = 86400
|
||||
findtime = 600
|
||||
maxretry = 3
|
||||
ignoreip = 127.0.0.1/8 ::1
|
||||
|
||||
[sshd]
|
||||
enabled = true
|
||||
port = ssh
|
||||
filter = sshd
|
||||
logpath = /var/log/secure
|
||||
maxretry = 3
|
||||
bantime = 86400
|
||||
EOF
|
||||
systemctl restart fail2ban
|
||||
sleep 2
|
||||
fail2ban-client status sshd
|
||||
|
||||
# 4. NTP / timezone
|
||||
echo "[4/8] Configuring NTP..."
|
||||
timedatectl set-timezone UTC
|
||||
yum install -y -q chrony
|
||||
systemctl enable chronyd
|
||||
systemctl start chronyd
|
||||
|
||||
# 5. Disable unnecessary services (SSH not needed — managed via SSM only)
|
||||
echo "[5/8] Disabling unnecessary services..."
|
||||
for svc in postfix rpcbind rpcbind.socket sshd; do
|
||||
systemctl stop "$svc" 2>/dev/null || true
|
||||
systemctl disable "$svc" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# 6. Kernel hardening
|
||||
echo "[6/8] Kernel hardening..."
|
||||
cat > /etc/sysctl.d/99-vault1984.conf << 'EOF'
|
||||
net.ipv4.tcp_syncookies = 1
|
||||
net.ipv4.icmp_echo_ignore_broadcasts = 1
|
||||
net.ipv4.conf.all.rp_filter = 1
|
||||
net.ipv4.conf.default.rp_filter = 1
|
||||
EOF
|
||||
sysctl --system -q
|
||||
|
||||
# 7. Firewall — allow only vault1984 port (SSM doesn't need port 22)
|
||||
echo "[7/8] Configuring firewall..."
|
||||
systemctl enable firewalld
|
||||
systemctl start firewalld
|
||||
firewall-cmd --permanent --remove-service=ssh 2>/dev/null || true
|
||||
firewall-cmd --permanent --remove-service=dhcpv6-client 2>/dev/null || true
|
||||
firewall-cmd --permanent --add-port=1984/tcp
|
||||
firewall-cmd --reload
|
||||
firewall-cmd --list-all
|
||||
|
||||
# 8. sshd disabled — POPs are managed exclusively via AWS SSM
|
||||
echo "[8/8] Disabling sshd (SSM-managed, no SSH needed)..."
|
||||
systemctl stop sshd 2>/dev/null || true
|
||||
systemctl disable sshd 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "=== Hardening complete ==="
|
||||
echo " fail2ban: $(fail2ban-client status | grep 'Jail list' | sed 's/.*Jail list:\s*//')"
|
||||
echo " firewall: $(firewall-cmd --list-ports)"
|
||||
echo " rpcbind: $(systemctl is-active rpcbind 2>/dev/null || echo inactive)"
|
||||
echo " timezone: $(timedatectl | grep 'Time zone')"
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
#!/bin/bash
|
||||
# vault1984 POP Update Script — push latest binary + optional re-harden
|
||||
# Usage: ./update-pop.sh [--harden] [<node-id> <region> <instance-id>]
|
||||
# --harden Also re-apply hardening (fail2ban, firewall, sysctl, etc.)
|
||||
# no args Update all known POPs (binary + service only)
|
||||
# Example: ./update-pop.sh --harden virginia us-east-1 i-01613f301bc47418e
|
||||
# ./update-pop.sh --harden (all POPs)
|
||||
set -euo pipefail
|
||||
|
||||
HQ_URL="http://185.218.204.47:8080"
|
||||
APPLY_HARDEN=false
|
||||
HARDEN_SCRIPT="$(dirname "$0")/harden-pop.sh"
|
||||
|
||||
# Known POPs — update this list as new POPs are added
|
||||
declare -A POPS=(
|
||||
["virginia"]="us-east-1:i-01613f301bc47418e"
|
||||
["singapore"]="ap-southeast-1:i-03285633c3dcc64e1"
|
||||
["zurich"]="eu-central-2:i-0b907cebe7978c7c3"
|
||||
["saopaulo"]="sa-east-1:i-06485da3657e4a89b"
|
||||
)
|
||||
|
||||
# Parse args
|
||||
ARGS=()
|
||||
for arg in "$@"; do
|
||||
[[ "$arg" == "--harden" ]] && APPLY_HARDEN=true || ARGS+=("$arg")
|
||||
done
|
||||
set -- "${ARGS[@]+"${ARGS[@]}"}"
|
||||
|
||||
ssm_run() {
|
||||
local region=$1 iid=$2
|
||||
shift 2
|
||||
local cmds=("$@")
|
||||
local json_cmds
|
||||
json_cmds=$(python3 -c "import json,sys; print(json.dumps(sys.argv[1:]))" "${cmds[@]}")
|
||||
aws --region "$region" ssm send-command \
|
||||
--instance-ids "$iid" \
|
||||
--document-name AWS-RunShellScript \
|
||||
--parameters "commands=$json_cmds" \
|
||||
--query "Command.CommandId" --output text
|
||||
}
|
||||
|
||||
ssm_wait() {
|
||||
local region=$1 iid=$2 cmd_id=$3 label=$4
|
||||
for i in $(seq 1 18); do
|
||||
R=$(aws --region "$region" ssm get-command-invocation \
|
||||
--command-id "$cmd_id" --instance-id "$iid" \
|
||||
--query "{S:Status,O:StandardOutputContent,E:StandardErrorContent}" --output json 2>/dev/null || echo '{"S":"Pending"}')
|
||||
S=$(echo "$R" | python3 -c "import json,sys; print(json.load(sys.stdin).get('S','?'))")
|
||||
if [[ "$S" == "Success" || "$S" == "Failed" || "$S" == "TimedOut" ]]; then
|
||||
echo "$R" | python3 -c "
|
||||
import json,sys
|
||||
d=json.load(sys.stdin)
|
||||
out=d.get('O','').strip()
|
||||
err=d.get('E','').strip()
|
||||
if out: print(out)
|
||||
if err: print('STDERR:', err)
|
||||
"
|
||||
[[ "$S" == "Success" ]] && return 0 || { echo "FAILED ($S)"; return 1; }
|
||||
fi
|
||||
echo " ($i) $S..."
|
||||
sleep 10
|
||||
done
|
||||
echo "TIMEOUT"; return 1
|
||||
}
|
||||
|
||||
verify_sg() {
|
||||
local region=$1 iid=$2 node=$3
|
||||
ASSIGNED=$(aws --region "$region" ec2 describe-instances \
|
||||
--instance-ids "$iid" \
|
||||
--query "Reservations[0].Instances[0].SecurityGroups[0].GroupName" --output text 2>/dev/null || echo "unknown")
|
||||
if [[ "$ASSIGNED" != "vault1984-pop" ]]; then
|
||||
echo " ⚠️ SG mismatch on $node: got '$ASSIGNED', expected 'vault1984-pop'"
|
||||
echo " Attempting to reconcile..."
|
||||
# Get the correct vault1984-pop SG ID for this region
|
||||
VPC_ID=$(aws --region "$region" ec2 describe-vpcs \
|
||||
--filters "Name=isDefault,Values=true" --query "Vpcs[0].VpcId" --output text)
|
||||
SG_ID=$(aws --region "$region" ec2 describe-security-groups \
|
||||
--filters "Name=group-name,Values=vault1984-pop" "Name=vpc-id,Values=$VPC_ID" \
|
||||
--query "SecurityGroups[0].GroupId" --output text 2>/dev/null || echo "None")
|
||||
if [[ -z "$SG_ID" || "$SG_ID" == "None" ]]; then
|
||||
echo " vault1984-pop SG doesn't exist in $region — creating..."
|
||||
SG_ID=$(aws --region "$region" ec2 create-security-group \
|
||||
--group-name vault1984-pop \
|
||||
--description "Vault1984 POP - outbound only, no inbound" \
|
||||
--vpc-id "$VPC_ID" --query "GroupId" --output text)
|
||||
aws --region "$region" ec2 revoke-security-group-ingress \
|
||||
--group-id "$SG_ID" --protocol -1 --cidr 0.0.0.0/0 2>/dev/null || true
|
||||
aws --region "$region" ec2 revoke-security-group-ingress \
|
||||
--group-id "$SG_ID" --protocol -1 --source-group "$SG_ID" 2>/dev/null || true
|
||||
echo " Created SG: $SG_ID"
|
||||
fi
|
||||
aws --region "$region" ec2 modify-instance-attribute \
|
||||
--instance-id "$iid" --groups "$SG_ID"
|
||||
echo " ✓ SG reconciled → vault1984-pop ($SG_ID)"
|
||||
else
|
||||
echo " ✓ SG: vault1984-pop"
|
||||
fi
|
||||
}
|
||||
|
||||
update_pop() {
|
||||
local NODE_ID="$1" REGION="$2" INSTANCE_ID="$3"
|
||||
echo ""
|
||||
echo "━━━ $NODE_ID ($REGION / $INSTANCE_ID) ━━━"
|
||||
|
||||
# 1. SG verification + reconciliation
|
||||
echo " Verifying security group..."
|
||||
verify_sg "$REGION" "$INSTANCE_ID" "$NODE_ID"
|
||||
|
||||
# 2. Binary + service update
|
||||
echo " Updating vault1984 binary..."
|
||||
CMD_ID=$(ssm_run "$REGION" "$INSTANCE_ID" \
|
||||
"set -e" \
|
||||
"curl -sfo /usr/local/bin/vault1984.new $HQ_URL/download/vault1984-arm64" \
|
||||
"chmod +x /usr/local/bin/vault1984.new" \
|
||||
"mv /usr/local/bin/vault1984.new /usr/local/bin/vault1984" \
|
||||
"printf '[Unit]\nDescription=Vault1984\nAfter=network.target\n\n[Service]\nEnvironment=NODE_ID=$NODE_ID\nExecStart=/usr/local/bin/vault1984 --telemetry-freq=60 --telemetry-host=$HQ_URL/telemetry\nRestart=always\nRestartSec=10\nWorkingDirectory=/var/lib/vault1984\n\n[Install]\nWantedBy=multi-user.target\n' > /etc/systemd/system/vault1984.service" \
|
||||
"mkdir -p /var/lib/vault1984" \
|
||||
"systemctl stop v1984-agent 2>/dev/null || true" \
|
||||
"systemctl disable v1984-agent 2>/dev/null || true" \
|
||||
"systemctl daemon-reload && systemctl enable vault1984 && systemctl restart vault1984" \
|
||||
"sleep 2" \
|
||||
"systemctl is-active vault1984 && echo 'vault1984: OK' || echo 'vault1984: FAILED'")
|
||||
ssm_wait "$REGION" "$INSTANCE_ID" "$CMD_ID" "binary update"
|
||||
|
||||
# 3. Hardening re-apply (optional, --harden flag)
|
||||
if $APPLY_HARDEN; then
|
||||
echo " Re-applying hardening..."
|
||||
HARDEN_CONTENT=$(cat "$HARDEN_SCRIPT")
|
||||
CMD_ID=$(aws --region "$REGION" ssm send-command \
|
||||
--instance-ids "$INSTANCE_ID" \
|
||||
--document-name AWS-RunShellScript \
|
||||
--parameters "commands=[\"bash -s << 'HARDEN'\\n$(echo "$HARDEN_CONTENT" | python3 -c "import sys; print(sys.stdin.read().replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"'))")\\nHARDEN\"]" \
|
||||
--query "Command.CommandId" --output text 2>/dev/null || true)
|
||||
# Simpler approach: write script then run it
|
||||
CMD_ID=$(aws --region "$REGION" ssm send-command \
|
||||
--instance-ids "$INSTANCE_ID" \
|
||||
--document-name AWS-RunShellScript \
|
||||
--parameters commands="[
|
||||
\"amazon-linux-extras install epel -y -q > /dev/null 2>&1 || true\",
|
||||
\"yum install -y -q fail2ban > /dev/null 2>&1 || true\",
|
||||
\"printf '[DEFAULT]\\nbantime = 86400\\nfindtime = 600\\nmaxretry = 3\\nignoreip = 127.0.0.1/8 ::1\\n\\n[sshd]\\nenabled = true\\nport = ssh\\nfilter = sshd\\nlogpath = /var/log/secure\\nmaxretry = 3\\nbantime = 86400\\n' > /etc/fail2ban/jail.local\",
|
||||
\"systemctl enable fail2ban && systemctl restart fail2ban\",
|
||||
\"sleep 5 && fail2ban-client status sshd\",
|
||||
\"for svc in postfix rpcbind sshd; do systemctl stop \$svc 2>/dev/null; systemctl disable \$svc 2>/dev/null; done; echo 'services disabled'\",
|
||||
\"echo 'Hardening applied'\"
|
||||
]" \
|
||||
--query "Command.CommandId" --output text)
|
||||
ssm_wait "$REGION" "$INSTANCE_ID" "$CMD_ID" "hardening"
|
||||
fi
|
||||
|
||||
# 4. Verify health
|
||||
echo " Verifying node health..."
|
||||
CMD_ID=$(ssm_run "$REGION" "$INSTANCE_ID" \
|
||||
"echo '--- vault1984 ---'" \
|
||||
"systemctl is-active vault1984 && echo 'OK' || echo 'FAILED'" \
|
||||
"echo '--- fail2ban ---'" \
|
||||
"systemctl is-active fail2ban && echo 'OK' || echo 'FAILED'" \
|
||||
"echo '--- fail2ban jails ---'" \
|
||||
"fail2ban-client status 2>/dev/null || echo 'no jails'" \
|
||||
"echo '--- sshd ---'" \
|
||||
"systemctl is-active sshd 2>/dev/null && echo 'WARNING: sshd still active' || echo 'disabled OK'" \
|
||||
"echo '--- rpcbind ---'" \
|
||||
"systemctl is-active rpcbind 2>/dev/null && echo 'WARNING: still active' || echo 'disabled OK'" \
|
||||
"echo '--- open ports ---'" \
|
||||
"ss -tlnp | grep LISTEN")
|
||||
ssm_wait "$REGION" "$INSTANCE_ID" "$CMD_ID" "verify"
|
||||
|
||||
echo " ✓ $NODE_ID done"
|
||||
}
|
||||
|
||||
# Main
|
||||
if [ ${#ARGS[@]} -eq 3 ]; then
|
||||
update_pop "${ARGS[0]}" "${ARGS[1]}" "${ARGS[2]}"
|
||||
elif [ ${#ARGS[@]} -eq 0 ]; then
|
||||
echo "=== vault1984 POP Update — all nodes$(${APPLY_HARDEN} && echo ' (+harden)' || echo '') ==="
|
||||
for NODE_ID in "${!POPS[@]}"; do
|
||||
IFS=: read -r REGION INSTANCE_ID <<< "${POPS[$NODE_ID]}"
|
||||
update_pop "$NODE_ID" "$REGION" "$INSTANCE_ID"
|
||||
done
|
||||
echo ""
|
||||
echo "=== All POPs updated ==="
|
||||
else
|
||||
echo "Usage: $0 [--harden] [<node-id> <region> <instance-id>]"
|
||||
exit 1
|
||||
fi
|
||||
Loading…
Reference in New Issue