Complete Phase 5B pairing flow
Add agent pairing token lifecycle, APK download serving, MemoriC Google sign-in and pairing UX polish, and update implementation plans for Phase 5C.
This commit is contained in:
391
agent/main.go
391
agent/main.go
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -41,6 +42,7 @@ type Config struct {
|
||||
BindHost string `json:"bind_host"`
|
||||
BindPort int `json:"bind_port"`
|
||||
AllowedSubnets []string `json:"allowed_subnets"`
|
||||
AuthMode string `json:"auth_mode"`
|
||||
PairingToken string `json:"pairing_token"`
|
||||
}
|
||||
|
||||
@@ -76,6 +78,8 @@ type StatusResponse struct {
|
||||
StorageDir string `json:"storage_dir"`
|
||||
BindAddr string `json:"bind_addr"`
|
||||
AllowedSubnets []string `json:"allowed_subnets"`
|
||||
AuthMode string `json:"auth_mode"`
|
||||
PairingReady bool `json:"pairing_ready"`
|
||||
UploadsHandled int64 `json:"uploads_handled"`
|
||||
FetchesHandled int64 `json:"fetches_handled"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
@@ -108,6 +112,18 @@ type MigrationStatus struct {
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
type PairingStatusResponse struct {
|
||||
Status string `json:"status"`
|
||||
AuthMode string `json:"auth_mode"`
|
||||
PairingReady bool `json:"pairing_ready"`
|
||||
CanReveal bool `json:"can_reveal"`
|
||||
}
|
||||
|
||||
type PairingTokenResponse struct {
|
||||
Status string `json:"status"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
func defaultStorageDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
@@ -130,6 +146,7 @@ func loadConfig() Config {
|
||||
BindHost: "0.0.0.0",
|
||||
BindPort: 5051,
|
||||
AllowedSubnets: append([]string(nil), defaultAllowedSubnets...),
|
||||
AuthMode: "pairing_token",
|
||||
}
|
||||
|
||||
if env := strings.TrimSpace(os.Getenv("STORAGE_DIR")); env != "" {
|
||||
@@ -182,12 +199,38 @@ func loadConfig() Config {
|
||||
if len(fileCfg.AllowedSubnets) > 0 {
|
||||
cfg.AllowedSubnets = fileCfg.AllowedSubnets
|
||||
}
|
||||
if strings.TrimSpace(fileCfg.AuthMode) != "" {
|
||||
cfg.AuthMode = strings.TrimSpace(fileCfg.AuthMode)
|
||||
}
|
||||
if strings.TrimSpace(fileCfg.PairingToken) != "" {
|
||||
cfg.PairingToken = strings.TrimSpace(fileCfg.PairingToken)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func generatePairingToken() (string, error) {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b[:]), nil
|
||||
}
|
||||
|
||||
func persistInitialConfig(cfg Config) error {
|
||||
return writeConfigFile(configPath(), cfg)
|
||||
}
|
||||
|
||||
func writeConfigFile(path string, cfg Config) error {
|
||||
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'), 0600)
|
||||
}
|
||||
|
||||
func parseAllowedSubnets(values []string) ([]netip.Prefix, error) {
|
||||
prefixes := make([]netip.Prefix, 0, len(values))
|
||||
for _, raw := range values {
|
||||
@@ -239,6 +282,8 @@ func (s *AgentState) status(allowed []string) StatusResponse {
|
||||
StorageDir: s.storageDir,
|
||||
BindAddr: s.bindAddr,
|
||||
AllowedSubnets: append([]string(nil), allowed...),
|
||||
AuthMode: s.config.AuthMode,
|
||||
PairingReady: strings.TrimSpace(s.token) != "",
|
||||
UploadsHandled: s.uploadsHandled,
|
||||
FetchesHandled: s.fetchesHandled,
|
||||
LastError: s.lastError,
|
||||
@@ -272,14 +317,31 @@ func (s *AgentState) persistConfig() error {
|
||||
path := s.configPath
|
||||
s.mu.RUnlock()
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return err
|
||||
return writeConfigFile(path, cfg)
|
||||
}
|
||||
|
||||
func (s *AgentState) persistConfigWithPairingToken(token string) error {
|
||||
s.mu.RLock()
|
||||
cfg := s.config
|
||||
cfg.StorageDir = s.storageDir
|
||||
cfg.PairingToken = token
|
||||
if strings.TrimSpace(cfg.AuthMode) == "" {
|
||||
cfg.AuthMode = "pairing_token"
|
||||
}
|
||||
b, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
path := s.configPath
|
||||
s.mu.RUnlock()
|
||||
|
||||
return writeConfigFile(path, cfg)
|
||||
}
|
||||
|
||||
func (s *AgentState) setPairingToken(token string) {
|
||||
s.mu.Lock()
|
||||
s.token = token
|
||||
s.config.PairingToken = token
|
||||
if strings.TrimSpace(s.config.AuthMode) == "" {
|
||||
s.config.AuthMode = "pairing_token"
|
||||
}
|
||||
return os.WriteFile(path, append(b, '\n'), 0644)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func validateStoragePath(raw string) StorageValidation {
|
||||
@@ -538,6 +600,23 @@ func (s *AgentState) clientAllowed(r *http.Request) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func requestIP(r *http.Request) (netip.Addr, bool) {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
ip, err := netip.ParseAddr(strings.Trim(host, "[]"))
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
return ip, true
|
||||
}
|
||||
|
||||
func (s *AgentState) clientLoopback(r *http.Request) bool {
|
||||
ip, ok := requestIP(r)
|
||||
return ok && ip.IsLoopback()
|
||||
}
|
||||
|
||||
func (s *AgentState) requireSubnet(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.clientAllowed(r) {
|
||||
@@ -568,6 +647,17 @@ func (s *AgentState) requireMutableAuth(next http.HandlerFunc) http.HandlerFunc
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AgentState) pairingStatus(r *http.Request) PairingStatusResponse {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return PairingStatusResponse{
|
||||
Status: "ok",
|
||||
AuthMode: s.config.AuthMode,
|
||||
PairingReady: strings.TrimSpace(s.token) != "",
|
||||
CanReveal: s.clientLoopback(r),
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) (string, error) {
|
||||
cleaned := strings.TrimSpace(name)
|
||||
if cleaned == "" {
|
||||
@@ -596,6 +686,24 @@ func (s *AgentState) saveUpload(filename string, data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func candidateAPKPaths() []string {
|
||||
paths := []string{"memoric.apk"}
|
||||
if exe, err := os.Executable(); err == nil && exe != "" {
|
||||
paths = append(paths, filepath.Join(filepath.Dir(exe), "memoric.apk"))
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func findAPKPath() (string, bool) {
|
||||
for _, path := range candidateAPKPaths() {
|
||||
info, err := os.Stat(path)
|
||||
if err == nil && !info.IsDir() {
|
||||
return path, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func (s *AgentState) startHTTPServer(allowedSubnets []string) {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
@@ -607,6 +715,53 @@ func (s *AgentState) startHTTPServer(allowedSubnets []string) {
|
||||
s.writeJSON(w, http.StatusOK, s.status(allowedSubnets))
|
||||
}))
|
||||
|
||||
mux.HandleFunc("/api/pairing/status", 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
|
||||
}
|
||||
s.writeJSON(w, http.StatusOK, s.pairingStatus(r))
|
||||
}))
|
||||
|
||||
mux.HandleFunc("/api/pairing/reveal", 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
|
||||
}
|
||||
if !s.clientLoopback(r) {
|
||||
s.writeJSON(w, http.StatusForbidden, map[string]string{"error": "pairing token can only be revealed from localhost"})
|
||||
return
|
||||
}
|
||||
s.mu.RLock()
|
||||
token := s.token
|
||||
s.mu.RUnlock()
|
||||
if strings.TrimSpace(token) == "" {
|
||||
s.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "pairing token is not configured"})
|
||||
return
|
||||
}
|
||||
s.writeJSON(w, http.StatusOK, PairingTokenResponse{Status: "ok", Token: token})
|
||||
}))
|
||||
|
||||
mux.HandleFunc("/api/pairing/rotate", 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
|
||||
}
|
||||
token, err := generatePairingToken()
|
||||
if err != nil {
|
||||
s.setError(err)
|
||||
s.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to generate pairing token"})
|
||||
return
|
||||
}
|
||||
if err := s.persistConfigWithPairingToken(token); err != nil {
|
||||
s.setError(err)
|
||||
s.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to persist pairing token"})
|
||||
return
|
||||
}
|
||||
s.setPairingToken(token)
|
||||
s.writeJSON(w, http.StatusOK, PairingTokenResponse{Status: "ok", Token: token})
|
||||
})))
|
||||
|
||||
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"})
|
||||
@@ -762,6 +917,21 @@ func (s *AgentState) startHTTPServer(allowedSubnets []string) {
|
||||
_, _ = w.Write(b)
|
||||
})))
|
||||
|
||||
mux.HandleFunc("/download/memoric.apk", s.requireSubnet(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
s.writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
return
|
||||
}
|
||||
path, ok := findAPKPath()
|
||||
if !ok {
|
||||
s.writeJSON(w, http.StatusNotFound, map[string]string{"error": "memoric.apk not installed on agent"})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="memoric.apk"`)
|
||||
http.ServeFile(w, r, path)
|
||||
}))
|
||||
|
||||
mux.HandleFunc("/", s.requireSubnet(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
@@ -786,11 +956,21 @@ func (s *AgentState) startHTTPServer(allowedSubnets []string) {
|
||||
</head>
|
||||
<body>
|
||||
<h1>MemoriC Agent</h1>
|
||||
<div class="box">
|
||||
<h3>Android App</h3>
|
||||
<div class="row"><a href="/download/memoric.apk">Download MemoriC APK</a></div>
|
||||
</div>
|
||||
<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>Pairing</h3>
|
||||
<div id="pairing">Loading...</div>
|
||||
<div class="row"><button id="show-token">Show Token</button> <button id="rotate-token">Rotate Token</button></div>
|
||||
<pre id="pairing-result"></pre>
|
||||
</div>
|
||||
<div class="box">
|
||||
<h3>Storage Settings</h3>
|
||||
<div class="row"><label>Storage Directory</label></div>
|
||||
@@ -805,9 +985,19 @@ async function loadStatus() {
|
||||
document.getElementById('status').innerText =
|
||||
'Broker: ' + (data.broker_connected ? 'connected' : 'disconnected') +
|
||||
'\nBind: ' + data.bind_addr +
|
||||
'\nAuth: ' + data.auth_mode + ' (' + (data.pairing_ready ? 'paired' : 'not ready') + ')' +
|
||||
'\nStorage: ' + data.storage_dir +
|
||||
'\nLast Error: ' + (data.last_error || '-');
|
||||
document.getElementById('path').value = data.storage_dir || '';
|
||||
await loadPairing();
|
||||
}
|
||||
async function loadPairing() {
|
||||
const res = await fetch('/api/pairing/status');
|
||||
const data = await res.json();
|
||||
document.getElementById('pairing').innerText =
|
||||
'Mode: ' + data.auth_mode +
|
||||
'\nReady: ' + (data.pairing_ready ? 'yes' : 'no') +
|
||||
'\nLocal token reveal: ' + (data.can_reveal ? 'available' : 'localhost only');
|
||||
}
|
||||
async function postJSON(url, body) {
|
||||
const token = prompt('Enter pairing token');
|
||||
@@ -820,6 +1010,21 @@ async function postJSON(url, body) {
|
||||
document.getElementById('result').innerText = JSON.stringify(data, null, 2);
|
||||
await loadStatus();
|
||||
}
|
||||
async function showToken() {
|
||||
const res = await fetch('/api/pairing/reveal');
|
||||
const data = await res.json();
|
||||
document.getElementById('pairing-result').innerText = JSON.stringify(data, null, 2);
|
||||
}
|
||||
async function rotateToken() {
|
||||
const token = prompt('Enter current pairing token');
|
||||
const res = await fetch('/api/pairing/rotate', {
|
||||
method: 'POST',
|
||||
headers: {'Authorization': 'Bearer ' + (token || '')}
|
||||
});
|
||||
const data = await res.json();
|
||||
document.getElementById('pairing-result').innerText = JSON.stringify(data, null, 2);
|
||||
await loadStatus();
|
||||
}
|
||||
async function loadMigration() {
|
||||
const res = await fetch('/api/storage/migration');
|
||||
const data = await res.json();
|
||||
@@ -829,6 +1034,8 @@ async function loadMigration() {
|
||||
}
|
||||
}
|
||||
document.getElementById('refresh').onclick = loadStatus;
|
||||
document.getElementById('show-token').onclick = showToken;
|
||||
document.getElementById('rotate-token').onclick = rotateToken;
|
||||
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();
|
||||
@@ -889,8 +1096,106 @@ func getLANEndpoints(bindHost string, bindPort int) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *AgentState) runBrokerSession(cfg Config) {
|
||||
wsURL := brokerURL
|
||||
if s.token != "" {
|
||||
wsURL = brokerURL + "?token=" + url.QueryEscape(s.token)
|
||||
}
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
s.setConnected(false)
|
||||
s.setError(err)
|
||||
log.Printf("broker connect failed: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
s.setConnected(true)
|
||||
log.Println("connected to broker at", wsURL)
|
||||
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 {
|
||||
s.setConnected(false)
|
||||
s.setError(err)
|
||||
log.Printf("failed sending agent_hello: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
s.setConnected(false)
|
||||
s.setError(err)
|
||||
log.Printf("broker read failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var msg Message
|
||||
if err := json.Unmarshal(raw, &msg); err != nil {
|
||||
s.setError(err)
|
||||
log.Println("bad message:", err)
|
||||
continue
|
||||
}
|
||||
|
||||
switch msg.Type {
|
||||
case "upload":
|
||||
data, err := base64.StdEncoding.DecodeString(msg.Data)
|
||||
if err != nil {
|
||||
s.setError(err)
|
||||
log.Println("decode error:", err)
|
||||
continue
|
||||
}
|
||||
if err := s.saveUpload(msg.Filename, data); err != nil {
|
||||
s.setError(err)
|
||||
log.Println("write error:", err)
|
||||
continue
|
||||
}
|
||||
log.Printf("saved %s (%d bytes)", msg.Filename, len(data))
|
||||
|
||||
case "fetch":
|
||||
path := filepath.Join(s.getStorageDir(), 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)
|
||||
s.incFetch()
|
||||
log.Printf("served %s (%d bytes)", msg.Filename, len(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
cfg := loadConfig()
|
||||
if strings.TrimSpace(cfg.AuthMode) == "" {
|
||||
cfg.AuthMode = "pairing_token"
|
||||
}
|
||||
if strings.TrimSpace(cfg.PairingToken) == "" {
|
||||
token, err := generatePairingToken()
|
||||
if err != nil {
|
||||
log.Fatalf("generate pairing token: %v", err)
|
||||
}
|
||||
cfg.PairingToken = token
|
||||
if err := persistInitialConfig(cfg); err != nil {
|
||||
log.Fatalf("persist generated pairing token: %v", err)
|
||||
}
|
||||
log.Printf("generated pairing token and saved config at %s", configPath())
|
||||
}
|
||||
prefixes, err := parseAllowedSubnets(cfg.AllowedSubnets)
|
||||
if err != nil {
|
||||
log.Fatalf("subnet config error: %v", err)
|
||||
@@ -918,78 +1223,8 @@ func main() {
|
||||
|
||||
go state.startHTTPServer(cfg.AllowedSubnets)
|
||||
|
||||
wsURL := brokerURL
|
||||
if state.token != "" {
|
||||
wsURL = brokerURL + "?token=" + url.QueryEscape(state.token)
|
||||
}
|
||||
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
|
||||
if err != nil {
|
||||
log.Fatal("connect:", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
state.setConnected(true)
|
||||
log.Println("connected to broker at", wsURL)
|
||||
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()
|
||||
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
|
||||
}
|
||||
if err := state.saveUpload(msg.Filename, data); err != nil {
|
||||
state.setError(err)
|
||||
log.Println("write error:", err)
|
||||
continue
|
||||
}
|
||||
log.Printf("saved %s (%d bytes)", msg.Filename, len(data))
|
||||
|
||||
case "fetch":
|
||||
path := filepath.Join(state.getStorageDir(), 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))
|
||||
}
|
||||
state.runBrokerSession(cfg)
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user