Complete Phase 4b direct fullscreen fetch and ship app/agent updates

This commit is contained in:
2026-05-18 14:31:20 +08:00
parent 46772a1448
commit 71872210d2
158 changed files with 1155 additions and 167 deletions

5
agent/go.mod Normal file
View 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
View 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
View 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"

View 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"

View File

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

View 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

View 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"