Add Phase 1 LAN bind and token-guarded agent API
This commit is contained in:
70
README.md
70
README.md
@@ -17,14 +17,16 @@ Built on the Clouderized platform (dogfooding).
|
|||||||
|------|----------|
|
|------|----------|
|
||||||
| [SPEC.md](SPEC.md) | Full product spec — architecture, data flow, tiers |
|
| [SPEC.md](SPEC.md) | Full product spec — architecture, data flow, tiers |
|
||||||
| [PLAN.md](PLAN.md) | Build plan — phases and milestones |
|
| [PLAN.md](PLAN.md) | Build plan — phases and milestones |
|
||||||
| [poc/](poc/) | Proof-of-concept code (tunnel broker, Go agent, Flutter app) |
|
| [memory/](memory/) | Broker service (FastAPI) |
|
||||||
|
| [agent/](agent/) | Home agent (Go) |
|
||||||
|
| [android/](android/) | Android app (Flutter) |
|
||||||
|
|
||||||
## Run Agent As User Service (Linux)
|
## Run Agent As User Service (Linux)
|
||||||
|
|
||||||
From repo root:
|
From repo root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd poc/agent
|
cd agent
|
||||||
./install-user-service.sh
|
./install-user-service.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -47,3 +49,67 @@ and change `Environment=STORAGE_DIR=...`, then run:
|
|||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
systemctl --user restart memoric-agent
|
systemctl --user restart memoric-agent
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Run Agent On Windows (User Task)
|
||||||
|
|
||||||
|
From PowerShell (repo root):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd agent
|
||||||
|
.\install-windows-task.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs a per-user Scheduled Task (`MemoriCAgent`) that starts on login
|
||||||
|
and writes photos to:
|
||||||
|
- `%USERPROFILE%\memoric\photos`
|
||||||
|
|
||||||
|
Manage with:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
schtasks /Query /TN MemoriCAgent /V /FO LIST
|
||||||
|
schtasks /Run /TN MemoriCAgent
|
||||||
|
schtasks /End /TN MemoriCAgent
|
||||||
|
cd agent
|
||||||
|
.\uninstall-windows-task.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Agent LAN Bind and Security (Phase 1)
|
||||||
|
|
||||||
|
The agent now exposes a local API/UI listener for LAN-ready control.
|
||||||
|
|
||||||
|
Defaults:
|
||||||
|
- `bind_host`: `0.0.0.0`
|
||||||
|
- `bind_port`: `5051`
|
||||||
|
- `allowed_subnets`: private ranges (`10/8`, `172.16/12`, `192.168/16`, loopback, ULA/link-local IPv6)
|
||||||
|
|
||||||
|
Config file path:
|
||||||
|
- Linux/macOS: `~/.config/memoric-agent/config.json`
|
||||||
|
- Windows: `%AppData%\memoric-agent\config.json`
|
||||||
|
|
||||||
|
Example config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"storage_dir": "/home/you/memoric/photos",
|
||||||
|
"bind_host": "0.0.0.0",
|
||||||
|
"bind_port": 5051,
|
||||||
|
"allowed_subnets": ["192.168.0.0/16", "10.0.0.0/8", "127.0.0.0/8"],
|
||||||
|
"pairing_token": "change-me"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Environment overrides (optional):
|
||||||
|
- `STORAGE_DIR`
|
||||||
|
- `AGENT_BIND_HOST`
|
||||||
|
- `AGENT_BIND_PORT`
|
||||||
|
- `AGENT_ALLOWED_SUBNETS` (comma-separated CIDRs)
|
||||||
|
- `AGENT_PAIRING_TOKEN`
|
||||||
|
|
||||||
|
Phase 1 endpoints:
|
||||||
|
- `GET /healthz` (subnet-restricted)
|
||||||
|
- `GET /api/status` (subnet-restricted)
|
||||||
|
- `POST /api/control/ping` (subnet-restricted + token auth)
|
||||||
|
|
||||||
|
Auth for mutable endpoints:
|
||||||
|
- `Authorization: Bearer <pairing_token>` or `X-Agent-Token: <pairing_token>`
|
||||||
|
- If no `pairing_token` is configured, mutable endpoints are disabled.
|
||||||
|
|||||||
387
agent/main.go
Normal file
387
agent/main.go
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
const brokerURL = "wss://memory.clouderized.com/agent"
|
||||||
|
|
||||||
|
var defaultAllowedSubnets = []string{
|
||||||
|
"10.0.0.0/8",
|
||||||
|
"172.16.0.0/12",
|
||||||
|
"192.168.0.0/16",
|
||||||
|
"127.0.0.0/8",
|
||||||
|
"::1/128",
|
||||||
|
"fc00::/7",
|
||||||
|
"fe80::/10",
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
StorageDir string `json:"storage_dir"`
|
||||||
|
BindHost string `json:"bind_host"`
|
||||||
|
BindPort int `json:"bind_port"`
|
||||||
|
AllowedSubnets []string `json:"allowed_subnets"`
|
||||||
|
PairingToken string `json:"pairing_token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Filename string `json:"filename,omitempty"`
|
||||||
|
Data string `json:"data,omitempty"`
|
||||||
|
RequestID string `json:"request_id,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentState struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
storageDir string
|
||||||
|
connected bool
|
||||||
|
lastError string
|
||||||
|
uploadsHandled int64
|
||||||
|
fetchesHandled int64
|
||||||
|
startedAt time.Time
|
||||||
|
allowedPrefixes []netip.Prefix
|
||||||
|
token string
|
||||||
|
bindAddr string
|
||||||
|
}
|
||||||
|
|
||||||
|
type StatusResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
BrokerConnected bool `json:"broker_connected"`
|
||||||
|
StorageDir string `json:"storage_dir"`
|
||||||
|
BindAddr string `json:"bind_addr"`
|
||||||
|
AllowedSubnets []string `json:"allowed_subnets"`
|
||||||
|
UploadsHandled int64 `json:"uploads_handled"`
|
||||||
|
FetchesHandled int64 `json:"fetches_handled"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
StartedAt string `json:"started_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultStorageDir() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil || home == "" {
|
||||||
|
return "./photos"
|
||||||
|
}
|
||||||
|
return filepath.Join(home, "memoric", "photos")
|
||||||
|
}
|
||||||
|
|
||||||
|
func configPath() string {
|
||||||
|
d, err := os.UserConfigDir()
|
||||||
|
if err != nil || d == "" {
|
||||||
|
return "memoric-agent.json"
|
||||||
|
}
|
||||||
|
return filepath.Join(d, "memoric-agent", "config.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadConfig() Config {
|
||||||
|
cfg := Config{
|
||||||
|
StorageDir: defaultStorageDir(),
|
||||||
|
BindHost: "0.0.0.0",
|
||||||
|
BindPort: 5051,
|
||||||
|
AllowedSubnets: append([]string(nil), defaultAllowedSubnets...),
|
||||||
|
}
|
||||||
|
|
||||||
|
if env := strings.TrimSpace(os.Getenv("STORAGE_DIR")); env != "" {
|
||||||
|
cfg.StorageDir = env
|
||||||
|
}
|
||||||
|
if env := strings.TrimSpace(os.Getenv("AGENT_BIND_HOST")); env != "" {
|
||||||
|
cfg.BindHost = env
|
||||||
|
}
|
||||||
|
if env := strings.TrimSpace(os.Getenv("AGENT_BIND_PORT")); env != "" {
|
||||||
|
var port int
|
||||||
|
if _, err := fmt.Sscanf(env, "%d", &port); err == nil && port > 0 && port < 65536 {
|
||||||
|
cfg.BindPort = port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if env := strings.TrimSpace(os.Getenv("AGENT_ALLOWED_SUBNETS")); env != "" {
|
||||||
|
parts := strings.Split(env, ",")
|
||||||
|
var out []string
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) > 0 {
|
||||||
|
cfg.AllowedSubnets = out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if env := strings.TrimSpace(os.Getenv("AGENT_PAIRING_TOKEN")); env != "" {
|
||||||
|
cfg.PairingToken = env
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := os.ReadFile(configPath())
|
||||||
|
if err != nil {
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
var fileCfg Config
|
||||||
|
if err := json.Unmarshal(b, &fileCfg); err != nil {
|
||||||
|
log.Printf("config parse error (%s): %v", configPath(), err)
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(fileCfg.StorageDir) != "" {
|
||||||
|
cfg.StorageDir = strings.TrimSpace(fileCfg.StorageDir)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(fileCfg.BindHost) != "" {
|
||||||
|
cfg.BindHost = strings.TrimSpace(fileCfg.BindHost)
|
||||||
|
}
|
||||||
|
if fileCfg.BindPort > 0 && fileCfg.BindPort < 65536 {
|
||||||
|
cfg.BindPort = fileCfg.BindPort
|
||||||
|
}
|
||||||
|
if len(fileCfg.AllowedSubnets) > 0 {
|
||||||
|
cfg.AllowedSubnets = fileCfg.AllowedSubnets
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(fileCfg.PairingToken) != "" {
|
||||||
|
cfg.PairingToken = strings.TrimSpace(fileCfg.PairingToken)
|
||||||
|
}
|
||||||
|
return cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAllowedSubnets(values []string) ([]netip.Prefix, error) {
|
||||||
|
prefixes := make([]netip.Prefix, 0, len(values))
|
||||||
|
for _, raw := range values {
|
||||||
|
p, err := netip.ParsePrefix(strings.TrimSpace(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid subnet %q: %w", raw, err)
|
||||||
|
}
|
||||||
|
prefixes = append(prefixes, p)
|
||||||
|
}
|
||||||
|
if len(prefixes) == 0 {
|
||||||
|
return nil, errors.New("allowed_subnets must not be empty")
|
||||||
|
}
|
||||||
|
return prefixes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AgentState) setConnected(v bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.connected = v
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AgentState) setError(err error) {
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.lastError = err.Error()
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AgentState) incUpload() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.uploadsHandled++
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AgentState) incFetch() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.fetchesHandled++
|
||||||
|
s.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AgentState) status(allowed []string) StatusResponse {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
return StatusResponse{
|
||||||
|
Status: "ok",
|
||||||
|
BrokerConnected: s.connected,
|
||||||
|
StorageDir: s.storageDir,
|
||||||
|
BindAddr: s.bindAddr,
|
||||||
|
AllowedSubnets: append([]string(nil), allowed...),
|
||||||
|
UploadsHandled: s.uploadsHandled,
|
||||||
|
FetchesHandled: s.fetchesHandled,
|
||||||
|
LastError: s.lastError,
|
||||||
|
StartedAt: s.startedAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AgentState) writeJSON(w http.ResponseWriter, code int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AgentState) clientAllowed(r *http.Request) bool {
|
||||||
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
host = r.RemoteAddr
|
||||||
|
}
|
||||||
|
ip, err := netip.ParseAddr(strings.Trim(host, "[]"))
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, p := range s.allowedPrefixes {
|
||||||
|
if p.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AgentState) requireSubnet(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.clientAllowed(r) {
|
||||||
|
s.writeJSON(w, http.StatusForbidden, map[string]string{"error": "client not in allowed_subnets"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AgentState) requireMutableAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := strings.TrimSpace(s.token)
|
||||||
|
if token == "" {
|
||||||
|
s.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "mutable endpoints disabled: pairing token not set"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
auth := strings.TrimSpace(r.Header.Get("Authorization"))
|
||||||
|
xToken := strings.TrimSpace(r.Header.Get("X-Agent-Token"))
|
||||||
|
if strings.HasPrefix(strings.ToLower(auth), "bearer ") {
|
||||||
|
auth = strings.TrimSpace(auth[7:])
|
||||||
|
}
|
||||||
|
if auth != token && xToken != token {
|
||||||
|
s.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *AgentState) startHTTPServer(allowedSubnets []string) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
mux.HandleFunc("/healthz", s.requireSubnet(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}))
|
||||||
|
|
||||||
|
mux.HandleFunc("/api/status", s.requireSubnet(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.writeJSON(w, http.StatusOK, s.status(allowedSubnets))
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mutable placeholder endpoint for Phase 1 auth enforcement.
|
||||||
|
mux.HandleFunc("/api/control/ping", s.requireSubnet(s.requireMutableAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "message": "authorized mutable endpoint"})
|
||||||
|
})))
|
||||||
|
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: s.bindAddr,
|
||||||
|
Handler: mux,
|
||||||
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
|
}
|
||||||
|
log.Printf("agent UI/API listening on http://%s", s.bindAddr)
|
||||||
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
s.setError(err)
|
||||||
|
log.Fatalf("http server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := loadConfig()
|
||||||
|
prefixes, err := parseAllowedSubnets(cfg.AllowedSubnets)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("subnet config error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(cfg.StorageDir, 0755); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state := &AgentState{
|
||||||
|
storageDir: cfg.StorageDir,
|
||||||
|
startedAt: time.Now().UTC(),
|
||||||
|
allowedPrefixes: prefixes,
|
||||||
|
token: strings.TrimSpace(cfg.PairingToken),
|
||||||
|
bindAddr: fmt.Sprintf("%s:%d", cfg.BindHost, cfg.BindPort),
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("photo storage directory:", cfg.StorageDir)
|
||||||
|
log.Println("allowed subnets:", strings.Join(cfg.AllowedSubnets, ", "))
|
||||||
|
if state.token == "" {
|
||||||
|
log.Println("pairing token not set: mutable endpoints disabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
go state.startHTTPServer(cfg.AllowedSubnets)
|
||||||
|
|
||||||
|
conn, _, err := websocket.DefaultDialer.Dial(brokerURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("connect:", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
state.setConnected(true)
|
||||||
|
log.Println("connected to broker at", brokerURL)
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, raw, err := conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
state.setConnected(false)
|
||||||
|
state.setError(err)
|
||||||
|
log.Fatal("read:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg Message
|
||||||
|
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||||
|
state.setError(err)
|
||||||
|
log.Println("bad message:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch msg.Type {
|
||||||
|
case "upload":
|
||||||
|
data, err := base64.StdEncoding.DecodeString(msg.Data)
|
||||||
|
if err != nil {
|
||||||
|
state.setError(err)
|
||||||
|
log.Println("decode error:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.Join(cfg.StorageDir, msg.Filename)
|
||||||
|
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||||
|
state.setError(err)
|
||||||
|
log.Println("write error:", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
state.incUpload()
|
||||||
|
log.Printf("saved %s (%d bytes)", msg.Filename, len(data))
|
||||||
|
|
||||||
|
case "fetch":
|
||||||
|
path := filepath.Join(cfg.StorageDir, msg.Filename)
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("fetch %s: not found", msg.Filename)
|
||||||
|
resp, _ := json.Marshal(Message{
|
||||||
|
Type: "fetch_response",
|
||||||
|
RequestID: msg.RequestID,
|
||||||
|
Error: "not found",
|
||||||
|
})
|
||||||
|
conn.WriteMessage(websocket.TextMessage, resp)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resp, _ := json.Marshal(Message{
|
||||||
|
Type: "fetch_response",
|
||||||
|
RequestID: msg.RequestID,
|
||||||
|
Data: base64.StdEncoding.EncodeToString(data),
|
||||||
|
})
|
||||||
|
conn.WriteMessage(websocket.TextMessage, resp)
|
||||||
|
state.incFetch()
|
||||||
|
log.Printf("served %s (%d bytes)", msg.Filename, len(data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user