Complete Phase 4b direct fullscreen fetch and ship app/agent updates
This commit is contained in:
5
agent/go.mod
Normal file
5
agent/go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module clouderized-memories-agent
|
||||
|
||||
go 1.21
|
||||
|
||||
require github.com/gorilla/websocket v1.5.3
|
||||
2
agent/go.sum
Normal file
2
agent/go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
44
agent/install-user-service.sh
Executable file
44
agent/install-user-service.sh
Executable file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
APP_DIR="${HOME}/.local/share/memoric-agent"
|
||||
SYSTEMD_USER_DIR="${HOME}/.config/systemd/user"
|
||||
AGENT_CONFIG_DIR="${HOME}/.config/memoric-agent"
|
||||
AGENT_ENV_FILE="${AGENT_CONFIG_DIR}/agent.env"
|
||||
|
||||
mkdir -p "${APP_DIR}"
|
||||
mkdir -p "${SYSTEMD_USER_DIR}"
|
||||
mkdir -p "${AGENT_CONFIG_DIR}"
|
||||
|
||||
echo "[1/4] Building agent binary..."
|
||||
cd "${SCRIPT_DIR}"
|
||||
go build -o "${APP_DIR}/memoric-agent" .
|
||||
|
||||
echo "[2/4] Installing systemd unit..."
|
||||
cp "${SCRIPT_DIR}/systemd/memoric-agent.service" "${SYSTEMD_USER_DIR}/memoric-agent.service"
|
||||
|
||||
if [[ ! -f "${AGENT_ENV_FILE}" ]]; then
|
||||
cat > "${AGENT_ENV_FILE}" <<'ENVEOF'
|
||||
# MemoriC agent runtime overrides (optional)
|
||||
# STORAGE_DIR=/home/your-user/memoric/photos
|
||||
# AGENT_BIND_HOST=0.0.0.0
|
||||
# AGENT_BIND_PORT=5051
|
||||
# AGENT_ALLOWED_SUBNETS=192.168.0.0/16,10.0.0.0/8,127.0.0.0/8
|
||||
# AGENT_PAIRING_TOKEN=change-me
|
||||
ENVEOF
|
||||
echo "Created ${AGENT_ENV_FILE}"
|
||||
fi
|
||||
|
||||
echo "[3/4] Reloading user daemon..."
|
||||
systemctl --user daemon-reload
|
||||
|
||||
echo "[4/4] Enabling and restarting memoric-agent..."
|
||||
systemctl --user enable memoric-agent
|
||||
systemctl --user restart memoric-agent
|
||||
|
||||
echo
|
||||
echo "MemoriC agent service installed."
|
||||
echo "Optional runtime overrides: ${AGENT_ENV_FILE}"
|
||||
echo "Check status with: systemctl --user status memoric-agent"
|
||||
echo "View logs with: journalctl --user -u memoric-agent -f"
|
||||
42
agent/install-windows-task.ps1
Normal file
42
agent/install-windows-task.ps1
Normal file
@@ -0,0 +1,42 @@
|
||||
Param(
|
||||
[string]$TaskName = "MemoriCAgent",
|
||||
[string]$InstallDir = "$env:LOCALAPPDATA\MemoriC-Agent",
|
||||
[string]$StorageDir = "$env:USERPROFILE\memoric\photos"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Host "[1/6] Creating install directories..."
|
||||
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path $StorageDir | Out-Null
|
||||
|
||||
Write-Host "[2/6] Building Windows agent binary..."
|
||||
Push-Location $PSScriptRoot
|
||||
go build -o "$InstallDir\memoric-agent.exe" .
|
||||
Pop-Location
|
||||
|
||||
Write-Host "[3/6] Writing launcher script..."
|
||||
$launcher = @"
|
||||
@echo off
|
||||
set STORAGE_DIR=$StorageDir
|
||||
"$InstallDir\memoric-agent.exe"
|
||||
"@
|
||||
Set-Content -Path "$InstallDir\run-agent.cmd" -Value $launcher -Encoding ASCII
|
||||
|
||||
Write-Host "[4/6] Stopping existing task instance (if running)..."
|
||||
schtasks /End /TN $TaskName 2>$null | Out-Null
|
||||
|
||||
Write-Host "[5/6] Registering scheduled task..."
|
||||
schtasks /Create /SC ONLOGON /TN $TaskName /TR "`"$InstallDir\run-agent.cmd`"" /RL LIMITED /F | Out-Null
|
||||
|
||||
Write-Host "[6/6] Starting task..."
|
||||
schtasks /Run /TN $TaskName | Out-Null
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "MemoriC agent task installed/updated and restarted."
|
||||
Write-Host "Task name: $TaskName"
|
||||
Write-Host "Install dir: $InstallDir"
|
||||
Write-Host "Storage dir: $StorageDir"
|
||||
Write-Host ""
|
||||
Write-Host "Check status with:"
|
||||
Write-Host " schtasks /Query /TN $TaskName /V /FO LIST"
|
||||
621
agent/main.go
621
agent/main.go
@@ -5,14 +5,19 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
@@ -39,11 +44,13 @@ type Config struct {
|
||||
}
|
||||
|
||||
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 string `json:"type"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
Data string `json:"data,omitempty"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
LANEndpoints []string `json:"lan_endpoints,omitempty"`
|
||||
BindPort int `json:"bind_port,omitempty"`
|
||||
}
|
||||
|
||||
type AgentState struct {
|
||||
@@ -57,6 +64,9 @@ type AgentState struct {
|
||||
allowedPrefixes []netip.Prefix
|
||||
token string
|
||||
bindAddr string
|
||||
configPath string
|
||||
config Config
|
||||
migration MigrationStatus
|
||||
}
|
||||
|
||||
type StatusResponse struct {
|
||||
@@ -71,6 +81,32 @@ type StatusResponse struct {
|
||||
StartedAt string `json:"started_at"`
|
||||
}
|
||||
|
||||
type StorageValidateRequest struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type StorageValidation struct {
|
||||
Valid bool `json:"valid"`
|
||||
Exists bool `json:"exists"`
|
||||
Writable bool `json:"writable"`
|
||||
Empty bool `json:"empty"`
|
||||
AbsPath string `json:"abs_path,omitempty"`
|
||||
Issue string `json:"issue,omitempty"`
|
||||
Suggestion string `json:"suggestion,omitempty"`
|
||||
}
|
||||
|
||||
type MigrationStatus struct {
|
||||
Status string `json:"status"`
|
||||
MigrationActive bool `json:"migration_active"`
|
||||
SourceDir string `json:"source_dir,omitempty"`
|
||||
DestDir string `json:"dest_dir,omitempty"`
|
||||
FilesMoved int `json:"files_moved,omitempty"`
|
||||
DirsCreated int `json:"dirs_created,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
FailedPaths []string `json:"failed_paths,omitempty"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
func defaultStorageDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
@@ -215,6 +251,275 @@ func (s *AgentState) writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func (s *AgentState) getStorageDir() string {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.storageDir
|
||||
}
|
||||
|
||||
func (s *AgentState) setStorageDir(path string) {
|
||||
s.mu.Lock()
|
||||
s.storageDir = path
|
||||
s.config.StorageDir = path
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *AgentState) persistConfig() error {
|
||||
s.mu.RLock()
|
||||
cfg := s.config
|
||||
cfg.StorageDir = s.storageDir
|
||||
path := s.configPath
|
||||
s.mu.RUnlock()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
b, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append(b, '\n'), 0644)
|
||||
}
|
||||
|
||||
func validateStoragePath(raw string) StorageValidation {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return StorageValidation{
|
||||
Valid: false,
|
||||
Issue: "path is required",
|
||||
Suggestion: "Provide a non-empty directory path.",
|
||||
}
|
||||
}
|
||||
|
||||
abs, err := filepath.Abs(raw)
|
||||
if err != nil {
|
||||
return StorageValidation{
|
||||
Valid: false,
|
||||
Issue: fmt.Sprintf("cannot resolve absolute path: %v", err),
|
||||
Suggestion: "Use a valid local filesystem path.",
|
||||
}
|
||||
}
|
||||
|
||||
v := StorageValidation{AbsPath: abs}
|
||||
info, statErr := os.Stat(abs)
|
||||
if statErr != nil {
|
||||
if errors.Is(statErr, fs.ErrNotExist) {
|
||||
return StorageValidation{
|
||||
Valid: false,
|
||||
AbsPath: abs,
|
||||
Exists: false,
|
||||
Issue: "directory does not exist",
|
||||
Suggestion: "Create the directory first, then retry.",
|
||||
}
|
||||
}
|
||||
return StorageValidation{
|
||||
Valid: false,
|
||||
AbsPath: abs,
|
||||
Issue: fmt.Sprintf("stat failed: %v", statErr),
|
||||
Suggestion: "Ensure the path is accessible.",
|
||||
}
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return StorageValidation{
|
||||
Valid: false,
|
||||
AbsPath: abs,
|
||||
Exists: true,
|
||||
Issue: "path is not a directory",
|
||||
Suggestion: "Choose a directory path.",
|
||||
}
|
||||
}
|
||||
v.Exists = true
|
||||
|
||||
entries, readErr := os.ReadDir(abs)
|
||||
if readErr != nil {
|
||||
v.Valid = false
|
||||
v.Issue = fmt.Sprintf("cannot read directory: %v", readErr)
|
||||
v.Suggestion = "Ensure the directory is readable."
|
||||
return v
|
||||
}
|
||||
v.Empty = len(entries) == 0
|
||||
|
||||
probe := filepath.Join(abs, ".memoric-agent-write-test")
|
||||
if werr := os.WriteFile(probe, []byte("ok"), 0600); werr != nil {
|
||||
v.Valid = false
|
||||
v.Issue = fmt.Sprintf("directory is not writable: %v", werr)
|
||||
v.Suggestion = "Grant write permission to the agent user."
|
||||
return v
|
||||
}
|
||||
_ = os.Remove(probe)
|
||||
v.Writable = true
|
||||
v.Valid = true
|
||||
return v
|
||||
}
|
||||
|
||||
func isSubPath(parent, child string) bool {
|
||||
rel, err := filepath.Rel(parent, child)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if rel == "." {
|
||||
return true
|
||||
}
|
||||
return !strings.HasPrefix(rel, ".."+string(os.PathSeparator)) && rel != ".."
|
||||
}
|
||||
|
||||
func copyFile(src, dst string, mode fs.FileMode) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode.Perm())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
_ = out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AgentState) setMigrationStatus(st MigrationStatus) {
|
||||
s.mu.Lock()
|
||||
s.migration = st
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *AgentState) getMigrationStatus() MigrationStatus {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := s.migration
|
||||
if out.FailedPaths != nil {
|
||||
out.FailedPaths = append([]string(nil), out.FailedPaths...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *AgentState) migrateStorage(src, dst string) MigrationStatus {
|
||||
status := MigrationStatus{
|
||||
Status: "running",
|
||||
MigrationActive: true,
|
||||
SourceDir: src,
|
||||
DestDir: dst,
|
||||
}
|
||||
s.setMigrationStatus(status)
|
||||
|
||||
info, err := os.Stat(src)
|
||||
if err != nil || !info.IsDir() {
|
||||
status.Status = "error"
|
||||
status.MigrationActive = false
|
||||
status.LastError = "source storage directory is not accessible"
|
||||
status.CompletedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
return status
|
||||
}
|
||||
if src == dst {
|
||||
status.Status = "rejected"
|
||||
status.MigrationActive = false
|
||||
status.LastError = "destination must differ from current storage directory"
|
||||
status.CompletedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
return status
|
||||
}
|
||||
if isSubPath(src, dst) || isSubPath(dst, src) {
|
||||
status.Status = "rejected"
|
||||
status.MigrationActive = false
|
||||
status.LastError = "source and destination cannot be nested inside each other"
|
||||
status.CompletedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
return status
|
||||
}
|
||||
|
||||
dstValidation := validateStoragePath(dst)
|
||||
if !dstValidation.Valid || !dstValidation.Exists || !dstValidation.Writable || !dstValidation.Empty {
|
||||
status.Status = "rejected"
|
||||
status.MigrationActive = false
|
||||
status.LastError = "destination failed validation (must exist, writable, and empty)"
|
||||
if dstValidation.Issue != "" {
|
||||
status.LastError = dstValidation.Issue
|
||||
}
|
||||
status.CompletedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
return status
|
||||
}
|
||||
|
||||
walkErr := filepath.WalkDir(src, func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
status.FailedPaths = append(status.FailedPaths, path+": "+walkErr.Error())
|
||||
return walkErr
|
||||
}
|
||||
if path == src {
|
||||
return nil
|
||||
}
|
||||
rel, err := filepath.Rel(src, path)
|
||||
if err != nil {
|
||||
status.FailedPaths = append(status.FailedPaths, path+": "+err.Error())
|
||||
return err
|
||||
}
|
||||
target := filepath.Join(dst, rel)
|
||||
if d.IsDir() {
|
||||
if err := os.MkdirAll(target, 0755); err != nil {
|
||||
status.FailedPaths = append(status.FailedPaths, path+": "+err.Error())
|
||||
return err
|
||||
}
|
||||
status.DirsCreated++
|
||||
return nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
status.FailedPaths = append(status.FailedPaths, path+": "+err.Error())
|
||||
return err
|
||||
}
|
||||
err = os.Rename(path, target)
|
||||
if err == nil {
|
||||
status.FilesMoved++
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, syscall.EXDEV) {
|
||||
status.FailedPaths = append(status.FailedPaths, path+": "+err.Error())
|
||||
return err
|
||||
}
|
||||
srcInfo, statErr := os.Stat(path)
|
||||
if statErr != nil {
|
||||
status.FailedPaths = append(status.FailedPaths, path+": "+statErr.Error())
|
||||
return statErr
|
||||
}
|
||||
if copyErr := copyFile(path, target, srcInfo.Mode()); copyErr != nil {
|
||||
status.FailedPaths = append(status.FailedPaths, path+": "+copyErr.Error())
|
||||
return copyErr
|
||||
}
|
||||
if removeErr := os.Remove(path); removeErr != nil {
|
||||
status.FailedPaths = append(status.FailedPaths, path+": "+removeErr.Error())
|
||||
return removeErr
|
||||
}
|
||||
status.FilesMoved++
|
||||
return nil
|
||||
})
|
||||
|
||||
if walkErr != nil {
|
||||
status.Status = "error"
|
||||
status.MigrationActive = false
|
||||
status.LastError = "migration failed before cutover; active storage path unchanged"
|
||||
status.CompletedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
return status
|
||||
}
|
||||
|
||||
s.setStorageDir(dst)
|
||||
if err := s.persistConfig(); err != nil {
|
||||
s.setStorageDir(src)
|
||||
status.Status = "error"
|
||||
status.MigrationActive = false
|
||||
status.LastError = fmt.Sprintf("migration completed but config persist failed, path reverted: %v", err)
|
||||
status.CompletedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
return status
|
||||
}
|
||||
|
||||
status.Status = "ok"
|
||||
status.MigrationActive = false
|
||||
status.CompletedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
return status
|
||||
}
|
||||
|
||||
func (s *AgentState) clientAllowed(r *http.Request) bool {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
@@ -262,6 +567,34 @@ func (s *AgentState) requireMutableAuth(next http.HandlerFunc) http.HandlerFunc
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) (string, error) {
|
||||
cleaned := strings.TrimSpace(name)
|
||||
if cleaned == "" {
|
||||
return "", errors.New("filename is required")
|
||||
}
|
||||
base := filepath.Base(cleaned)
|
||||
if base == "." || base == "/" || base == string(filepath.Separator) {
|
||||
return "", errors.New("invalid filename")
|
||||
}
|
||||
if strings.Contains(base, "..") || strings.ContainsRune(base, filepath.Separator) {
|
||||
return "", errors.New("invalid filename")
|
||||
}
|
||||
return base, nil
|
||||
}
|
||||
|
||||
func (s *AgentState) saveUpload(filename string, data []byte) error {
|
||||
safeName, err := sanitizeFilename(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(s.getStorageDir(), safeName)
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
s.incUpload()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AgentState) startHTTPServer(allowedSubnets []string) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
@@ -273,6 +606,80 @@ func (s *AgentState) startHTTPServer(allowedSubnets []string) {
|
||||
s.writeJSON(w, http.StatusOK, s.status(allowedSubnets))
|
||||
}))
|
||||
|
||||
mux.HandleFunc("/api/storage/validate", 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
|
||||
}
|
||||
var req StorageValidateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
v := validateStoragePath(req.Path)
|
||||
s.writeJSON(w, http.StatusOK, v)
|
||||
})))
|
||||
|
||||
mux.HandleFunc("/api/storage/change", 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
|
||||
}
|
||||
var req StorageValidateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
|
||||
return
|
||||
}
|
||||
v := validateStoragePath(req.Path)
|
||||
if !v.Valid || !v.Exists || !v.Writable || !v.Empty {
|
||||
s.writeJSON(w, http.StatusBadRequest, map[string]any{
|
||||
"status": "rejected",
|
||||
"validation": v,
|
||||
})
|
||||
return
|
||||
}
|
||||
src := s.getStorageDir()
|
||||
result := s.migrateStorage(src, v.AbsPath)
|
||||
s.setMigrationStatus(result)
|
||||
if result.Status != "ok" {
|
||||
code := http.StatusInternalServerError
|
||||
if result.Status == "rejected" {
|
||||
code = http.StatusBadRequest
|
||||
}
|
||||
if result.LastError != "" {
|
||||
s.setError(errors.New(result.LastError))
|
||||
}
|
||||
s.writeJSON(w, code, result)
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, http.StatusOK, map[string]any{
|
||||
"status": "ok",
|
||||
"storage_dir": v.AbsPath,
|
||||
"migration": result,
|
||||
"message": "storage migration completed and active path switched",
|
||||
})
|
||||
})))
|
||||
|
||||
mux.HandleFunc("/api/storage/migration", s.requireSubnet(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
st := s.getMigrationStatus()
|
||||
if st.Status == "" {
|
||||
st = MigrationStatus{
|
||||
Status: "idle",
|
||||
MigrationActive: false,
|
||||
SourceDir: s.getStorageDir(),
|
||||
}
|
||||
}
|
||||
s.writeJSON(w, http.StatusOK, map[string]any{
|
||||
"phase": "phase_3",
|
||||
"migration_supported": true,
|
||||
"migration": st,
|
||||
})
|
||||
}))
|
||||
|
||||
// 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 {
|
||||
@@ -282,6 +689,154 @@ func (s *AgentState) startHTTPServer(allowedSubnets []string) {
|
||||
s.writeJSON(w, http.StatusOK, map[string]string{"status": "ok", "message": "authorized mutable endpoint"})
|
||||
})))
|
||||
|
||||
mux.HandleFunc("/api/upload", 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
|
||||
}
|
||||
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
||||
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart upload"})
|
||||
return
|
||||
}
|
||||
fh, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing file field"})
|
||||
return
|
||||
}
|
||||
defer fh.Close()
|
||||
rawFilename := strings.TrimSpace(r.FormValue("filename"))
|
||||
if rawFilename == "" {
|
||||
rawFilename = header.Filename
|
||||
}
|
||||
safeName, err := sanitizeFilename(rawFilename)
|
||||
if err != nil {
|
||||
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
data, err := io.ReadAll(fh)
|
||||
if err != nil {
|
||||
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "failed to read upload stream"})
|
||||
return
|
||||
}
|
||||
if err := s.saveUpload(safeName, data); err != nil {
|
||||
s.setError(err)
|
||||
s.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to store upload"})
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, http.StatusOK, map[string]string{
|
||||
"status": "ok",
|
||||
"filename": safeName,
|
||||
"bytes": strconv.Itoa(len(data)),
|
||||
"path": filepath.Join(s.getStorageDir(), safeName),
|
||||
})
|
||||
})))
|
||||
|
||||
mux.HandleFunc("/api/photo/", s.requireSubnet(s.requireMutableAuth(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
name := strings.TrimPrefix(r.URL.Path, "/api/photo/")
|
||||
safeName, err := sanitizeFilename(name)
|
||||
if err != nil {
|
||||
s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
path := filepath.Join(s.getStorageDir(), safeName)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
s.writeJSON(w, http.StatusNotFound, map[string]string{"error": "photo not found"})
|
||||
return
|
||||
}
|
||||
s.setError(err)
|
||||
s.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to read photo"})
|
||||
return
|
||||
}
|
||||
s.incFetch()
|
||||
contentType := http.DetectContentType(b)
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Length", strconv.Itoa(len(b)))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(b)
|
||||
})))
|
||||
|
||||
mux.HandleFunc("/", s.requireSubnet(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write([]byte(`<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>MemoriC Agent</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; margin: 24px; line-height: 1.4; color: #1f2937; }
|
||||
h1 { margin-top: 0; }
|
||||
.box { border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
|
||||
.row { margin: 8px 0; }
|
||||
input { width: 100%; max-width: 680px; padding: 8px; }
|
||||
button { margin-top: 8px; padding: 8px 12px; }
|
||||
pre { background: #f8fafc; padding: 8px; border-radius: 6px; overflow: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>MemoriC Agent</h1>
|
||||
<div class="box">
|
||||
<h3>Status</h3>
|
||||
<div id="status">Loading...</div>
|
||||
<div class="row"><button id="refresh">Refresh</button></div>
|
||||
</div>
|
||||
<div class="box">
|
||||
<h3>Storage Settings</h3>
|
||||
<div class="row"><label>Storage Directory</label></div>
|
||||
<input id="path" placeholder="/path/to/photos" />
|
||||
<div class="row"><button id="validate">Validate</button> <button id="change">Migrate + Switch</button></div>
|
||||
<pre id="result"></pre>
|
||||
</div>
|
||||
<script>
|
||||
async function loadStatus() {
|
||||
const res = await fetch('/api/status');
|
||||
const data = await res.json();
|
||||
document.getElementById('status').innerText =
|
||||
'Broker: ' + (data.broker_connected ? 'connected' : 'disconnected') +
|
||||
'\nBind: ' + data.bind_addr +
|
||||
'\nStorage: ' + data.storage_dir +
|
||||
'\nLast Error: ' + (data.last_error || '-');
|
||||
document.getElementById('path').value = data.storage_dir || '';
|
||||
}
|
||||
async function postJSON(url, body) {
|
||||
const token = prompt('Enter pairing token');
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (token || '')},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await res.json();
|
||||
document.getElementById('result').innerText = JSON.stringify(data, null, 2);
|
||||
await loadStatus();
|
||||
}
|
||||
async function loadMigration() {
|
||||
const res = await fetch('/api/storage/migration');
|
||||
const data = await res.json();
|
||||
const prev = document.getElementById('result').innerText;
|
||||
if (!prev || prev === 'Loading...') {
|
||||
document.getElementById('result').innerText = JSON.stringify(data, null, 2);
|
||||
}
|
||||
}
|
||||
document.getElementById('refresh').onclick = loadStatus;
|
||||
document.getElementById('validate').onclick = () => postJSON('/api/storage/validate', {path: document.getElementById('path').value});
|
||||
document.getElementById('change').onclick = () => postJSON('/api/storage/change', {path: document.getElementById('path').value});
|
||||
loadStatus();
|
||||
loadMigration();
|
||||
</script>
|
||||
</body>
|
||||
</html>`))
|
||||
}))
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: s.bindAddr,
|
||||
Handler: mux,
|
||||
@@ -294,6 +849,45 @@ func (s *AgentState) startHTTPServer(allowedSubnets []string) {
|
||||
}
|
||||
}
|
||||
|
||||
func getLANEndpoints(bindHost string, bindPort int) []string {
|
||||
seen := make(map[string]struct{})
|
||||
out := make([]string, 0, 8)
|
||||
|
||||
add := func(ip netip.Addr) {
|
||||
if !ip.IsValid() {
|
||||
return
|
||||
}
|
||||
if !(ip.IsPrivate() || ip.IsLoopback()) {
|
||||
return
|
||||
}
|
||||
key := net.JoinHostPort(ip.String(), strconv.Itoa(bindPort))
|
||||
if _, ok := seen[key]; ok {
|
||||
return
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, key)
|
||||
}
|
||||
|
||||
// Prefer the source IP selected by OS routing for outbound traffic.
|
||||
if udpConn, err := net.Dial("udp", "1.1.1.1:53"); err == nil {
|
||||
if la, ok := udpConn.LocalAddr().(*net.UDPAddr); ok && la != nil && la.IP != nil {
|
||||
if addr, err := netip.ParseAddr(strings.TrimSpace(la.IP.String())); err == nil {
|
||||
add(addr)
|
||||
}
|
||||
}
|
||||
_ = udpConn.Close()
|
||||
}
|
||||
|
||||
if h := strings.TrimSpace(bindHost); h != "" && h != "0.0.0.0" && h != "::" {
|
||||
if addr, err := netip.ParseAddr(h); err == nil {
|
||||
add(addr)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig()
|
||||
prefixes, err := parseAllowedSubnets(cfg.AllowedSubnets)
|
||||
@@ -311,6 +905,8 @@ func main() {
|
||||
allowedPrefixes: prefixes,
|
||||
token: strings.TrimSpace(cfg.PairingToken),
|
||||
bindAddr: fmt.Sprintf("%s:%d", cfg.BindHost, cfg.BindPort),
|
||||
configPath: configPath(),
|
||||
config: cfg,
|
||||
}
|
||||
|
||||
log.Println("photo storage directory:", cfg.StorageDir)
|
||||
@@ -328,6 +924,15 @@ func main() {
|
||||
defer conn.Close()
|
||||
state.setConnected(true)
|
||||
log.Println("connected to broker at", brokerURL)
|
||||
hello, _ := json.Marshal(Message{
|
||||
Type: "agent_hello",
|
||||
LANEndpoints: getLANEndpoints(cfg.BindHost, cfg.BindPort),
|
||||
BindPort: cfg.BindPort,
|
||||
})
|
||||
if err := conn.WriteMessage(websocket.TextMessage, hello); err != nil {
|
||||
state.setError(err)
|
||||
log.Printf("failed sending agent_hello: %v", err)
|
||||
}
|
||||
|
||||
for {
|
||||
_, raw, err := conn.ReadMessage()
|
||||
@@ -352,17 +957,15 @@ func main() {
|
||||
log.Println("decode error:", err)
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(cfg.StorageDir, msg.Filename)
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
if err := state.saveUpload(msg.Filename, data); 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)
|
||||
path := filepath.Join(state.getStorageDir(), msg.Filename)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
log.Printf("fetch %s: not found", msg.Filename)
|
||||
|
||||
15
agent/systemd/memoric-agent.service
Normal file
15
agent/systemd/memoric-agent.service
Normal file
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=MemoriC Agent
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=%h/.local/share/memoric-agent
|
||||
EnvironmentFile=-%h/.config/memoric-agent/agent.env
|
||||
ExecStart=%h/.local/share/memoric-agent/memoric-agent
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
10
agent/uninstall-windows-task.ps1
Normal file
10
agent/uninstall-windows-task.ps1
Normal file
@@ -0,0 +1,10 @@
|
||||
Param(
|
||||
[string]$TaskName = "MemoriCAgent"
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
schtasks /End /TN $TaskName 2>$null | Out-Null
|
||||
schtasks /Delete /TN $TaskName /F 2>$null | Out-Null
|
||||
|
||||
Write-Host "Removed task: $TaskName"
|
||||
Reference in New Issue
Block a user