Skip to content

Commit f98b274

Browse files
committed
Require email for daemon startup, persist in account file
Like certbot, the daemon now requires an email address on first run. The email is persisted alongside the identity file so subsequent restarts don't need it. Resolution order: -email flag > -owner flag (deprecated) > account.json file. Add internal/validate for email format checks and internal/account for atomic account file persistence. Six integration tests cover the full lifecycle: required, persistence, reload, update, validation, and backward compatibility with the -owner flag.
1 parent 97a214e commit f98b274

10 files changed

Lines changed: 534 additions & 8 deletions

File tree

cmd/daemon/main.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ func main() {
2424
registryTLS := flag.Bool("registry-tls", false, "use TLS for registry connection")
2525
registryFingerprint := flag.String("registry-fingerprint", "", "hex SHA-256 fingerprint of registry TLS certificate")
2626
identityPath := flag.String("identity", "", "path to persist Ed25519 identity (enables stable identity across restarts)")
27-
owner := flag.String("owner", "", "owner identifier (email) for key rotation recovery")
27+
email := flag.String("email", "", "email address for account identification and key recovery")
28+
owner := flag.String("owner", "", "(deprecated: use -email) owner identifier for key rotation recovery")
2829
keepalive := flag.Duration("keepalive", 0, "keepalive probe interval (default 30s)")
2930
idleTimeout := flag.Duration("idle-timeout", 0, "idle connection timeout (default 120s)")
3031
synRate := flag.Int("syn-rate-limit", 0, "max SYN packets per second (default 100)")
@@ -62,6 +63,7 @@ func main() {
6263
RegistryTLS: *registryTLS,
6364
RegistryFingerprint: *registryFingerprint,
6465
IdentityPath: *identityPath,
66+
Email: *email,
6567
Owner: *owner,
6668
KeepaliveInterval: *keepalive,
6769
IdleTimeout: *idleTimeout,

internal/account/account.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package account
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
9+
"github.com/TeoSlayer/pilotprotocol/internal/fsutil"
10+
)
11+
12+
// Account holds persisted account information alongside the identity file.
13+
type Account struct {
14+
Email string `json:"email"`
15+
}
16+
17+
// Save writes the account to disk atomically with 0600 permissions.
18+
func Save(path string, acct *Account) error {
19+
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
20+
return fmt.Errorf("create account dir: %w", err)
21+
}
22+
data, err := json.MarshalIndent(acct, "", " ")
23+
if err != nil {
24+
return fmt.Errorf("marshal account: %w", err)
25+
}
26+
if err := fsutil.AtomicWrite(path, data); err != nil {
27+
return fmt.Errorf("write account: %w", err)
28+
}
29+
// Ensure 0600 permissions
30+
return os.Chmod(path, 0600)
31+
}
32+
33+
// Load reads an account from disk. Returns nil, nil if the file does not exist.
34+
func Load(path string) (*Account, error) {
35+
data, err := os.ReadFile(path)
36+
if err != nil {
37+
if os.IsNotExist(err) {
38+
return nil, nil
39+
}
40+
return nil, fmt.Errorf("read account: %w", err)
41+
}
42+
var acct Account
43+
if err := json.Unmarshal(data, &acct); err != nil {
44+
return nil, fmt.Errorf("unmarshal account: %w", err)
45+
}
46+
return &acct, nil
47+
}
48+
49+
// PathFromIdentity returns the account file path derived from an identity file path.
50+
// If identity is "/var/lib/pilot/identity.json", returns "/var/lib/pilot/account.json".
51+
func PathFromIdentity(identityPath string) string {
52+
return filepath.Join(filepath.Dir(identityPath), "account.json")
53+
}

internal/account/account_test.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package account
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestSaveAndLoad(t *testing.T) {
10+
dir := t.TempDir()
11+
path := filepath.Join(dir, "account.json")
12+
13+
acct := &Account{Email: "user@example.com"}
14+
if err := Save(path, acct); err != nil {
15+
t.Fatalf("Save: %v", err)
16+
}
17+
18+
loaded, err := Load(path)
19+
if err != nil {
20+
t.Fatalf("Load: %v", err)
21+
}
22+
if loaded == nil {
23+
t.Fatal("Load returned nil")
24+
}
25+
if loaded.Email != "user@example.com" {
26+
t.Errorf("Email = %q, want %q", loaded.Email, "user@example.com")
27+
}
28+
29+
// Check file permissions
30+
info, err := os.Stat(path)
31+
if err != nil {
32+
t.Fatalf("Stat: %v", err)
33+
}
34+
if perm := info.Mode().Perm(); perm != 0600 {
35+
t.Errorf("permissions = %o, want 0600", perm)
36+
}
37+
}
38+
39+
func TestLoadNotFound(t *testing.T) {
40+
dir := t.TempDir()
41+
path := filepath.Join(dir, "nonexistent.json")
42+
43+
acct, err := Load(path)
44+
if err != nil {
45+
t.Fatalf("Load: %v", err)
46+
}
47+
if acct != nil {
48+
t.Errorf("expected nil account for nonexistent file, got %+v", acct)
49+
}
50+
}
51+
52+
func TestSaveOverwrite(t *testing.T) {
53+
dir := t.TempDir()
54+
path := filepath.Join(dir, "account.json")
55+
56+
if err := Save(path, &Account{Email: "old@example.com"}); err != nil {
57+
t.Fatalf("Save: %v", err)
58+
}
59+
if err := Save(path, &Account{Email: "new@example.com"}); err != nil {
60+
t.Fatalf("Save overwrite: %v", err)
61+
}
62+
63+
loaded, err := Load(path)
64+
if err != nil {
65+
t.Fatalf("Load: %v", err)
66+
}
67+
if loaded.Email != "new@example.com" {
68+
t.Errorf("Email = %q, want %q", loaded.Email, "new@example.com")
69+
}
70+
}
71+
72+
func TestPathFromIdentity(t *testing.T) {
73+
cases := []struct {
74+
input string
75+
want string
76+
}{
77+
{"/var/lib/pilot/identity.json", "/var/lib/pilot/account.json"},
78+
{"/home/user/.pilot/identity.json", "/home/user/.pilot/account.json"},
79+
{"identity.json", "account.json"},
80+
}
81+
for _, tc := range cases {
82+
got := PathFromIdentity(tc.input)
83+
if got != tc.want {
84+
t.Errorf("PathFromIdentity(%q) = %q, want %q", tc.input, got, tc.want)
85+
}
86+
}
87+
}
88+
89+
func TestSaveCreatesDirectory(t *testing.T) {
90+
dir := t.TempDir()
91+
path := filepath.Join(dir, "subdir", "account.json")
92+
93+
if err := Save(path, &Account{Email: "user@example.com"}); err != nil {
94+
t.Fatalf("Save with nested dir: %v", err)
95+
}
96+
97+
loaded, err := Load(path)
98+
if err != nil {
99+
t.Fatalf("Load: %v", err)
100+
}
101+
if loaded.Email != "user@example.com" {
102+
t.Errorf("Email = %q, want %q", loaded.Email, "user@example.com")
103+
}
104+
}

internal/validate/email.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package validate
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
// Email validates an email address with basic checks:
9+
// non-empty, contains exactly one @, domain has a dot, no spaces.
10+
// This is not a full RFC 5322 parser — just enough to catch typos.
11+
func Email(email string) error {
12+
if email == "" {
13+
return fmt.Errorf("email address is required")
14+
}
15+
if strings.Contains(email, " ") {
16+
return fmt.Errorf("email address must not contain spaces")
17+
}
18+
at := strings.Index(email, "@")
19+
if at < 1 {
20+
return fmt.Errorf("email address must contain @")
21+
}
22+
if strings.Count(email, "@") > 1 {
23+
return fmt.Errorf("email address must contain exactly one @")
24+
}
25+
domain := email[at+1:]
26+
if domain == "" {
27+
return fmt.Errorf("email address must have a domain after @")
28+
}
29+
if !strings.Contains(domain, ".") {
30+
return fmt.Errorf("email domain must contain a dot")
31+
}
32+
if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
33+
return fmt.Errorf("email domain must not start or end with a dot")
34+
}
35+
return nil
36+
}

internal/validate/email_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package validate
2+
3+
import "testing"
4+
5+
func TestEmailValid(t *testing.T) {
6+
valid := []string{
7+
"user@example.com",
8+
"admin@pilot.local",
9+
"test+tag@sub.domain.org",
10+
"a@b.co",
11+
}
12+
for _, email := range valid {
13+
if err := Email(email); err != nil {
14+
t.Errorf("Email(%q) = %v, want nil", email, err)
15+
}
16+
}
17+
}
18+
19+
func TestEmailInvalid(t *testing.T) {
20+
cases := []struct {
21+
input string
22+
want string
23+
}{
24+
{"", "required"},
25+
{"noatsign", "must contain @"},
26+
{"@domain.com", "must contain @"},
27+
{"user@", "must have a domain"},
28+
{"user@nodot", "must contain a dot"},
29+
{"user @example.com", "must not contain spaces"},
30+
{"user@ example.com", "must not contain spaces"},
31+
{"user@.example.com", "must not start or end with a dot"},
32+
{"user@example.", "must not start or end with a dot"},
33+
{"a@b@c.com", "exactly one @"},
34+
}
35+
for _, tc := range cases {
36+
err := Email(tc.input)
37+
if err == nil {
38+
t.Errorf("Email(%q) = nil, want error containing %q", tc.input, tc.want)
39+
continue
40+
}
41+
if got := err.Error(); !contains(got, tc.want) {
42+
t.Errorf("Email(%q) = %q, want error containing %q", tc.input, got, tc.want)
43+
}
44+
}
45+
}
46+
47+
func contains(s, substr string) bool {
48+
return len(s) >= len(substr) && containsAt(s, substr)
49+
}
50+
51+
func containsAt(s, substr string) bool {
52+
for i := 0; i <= len(s)-len(substr); i++ {
53+
if s[i:i+len(substr)] == substr {
54+
return true
55+
}
56+
}
57+
return false
58+
}

pkg/daemon/daemon.go

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ import (
1010
"sync/atomic"
1111
"time"
1212

13+
"github.com/TeoSlayer/pilotprotocol/internal/account"
1314
"github.com/TeoSlayer/pilotprotocol/internal/crypto"
15+
"github.com/TeoSlayer/pilotprotocol/internal/validate"
1416
"github.com/TeoSlayer/pilotprotocol/pkg/protocol"
1517
"github.com/TeoSlayer/pilotprotocol/pkg/registry"
1618
)
@@ -29,7 +31,8 @@ type Config struct {
2931
RegistryTLS bool // use TLS for registry connection
3032
RegistryFingerprint string // hex SHA-256 fingerprint for TLS cert pinning
3133
IdentityPath string // path to persist Ed25519 identity (empty = no persistence)
32-
Owner string // owner identifier (email) for key rotation recovery
34+
Email string // email address for account identification and key recovery
35+
Owner string // deprecated: use Email instead
3336

3437
Endpoint string // fixed public endpoint (host:port) — skips STUN discovery (for cloud VMs)
3538
Public bool // make this node's endpoint publicly discoverable
@@ -250,6 +253,35 @@ func (d *Daemon) reapPerSrcSYN() {
250253
}
251254

252255
func (d *Daemon) Start() error {
256+
// 0. Resolve email: flag > owner (deprecated) > account file
257+
email := d.config.Email
258+
if email == "" && d.config.Owner != "" {
259+
email = d.config.Owner
260+
}
261+
if email == "" && d.config.IdentityPath != "" {
262+
acctPath := account.PathFromIdentity(d.config.IdentityPath)
263+
if acct, err := account.Load(acctPath); err == nil && acct != nil {
264+
email = acct.Email
265+
slog.Info("loaded email from account file", "path", acctPath)
266+
}
267+
}
268+
if email == "" {
269+
return fmt.Errorf("email address required: use -email you@example.com")
270+
}
271+
if err := validate.Email(email); err != nil {
272+
return fmt.Errorf("invalid email: %w", err)
273+
}
274+
d.config.Email = email
275+
d.config.Owner = email // keep Owner in sync for registry and DaemonInfo
276+
277+
// Persist email to account file (if identity path is set)
278+
if d.config.IdentityPath != "" {
279+
acctPath := account.PathFromIdentity(d.config.IdentityPath)
280+
if err := account.Save(acctPath, &account.Account{Email: email}); err != nil {
281+
slog.Warn("failed to save account file", "error", err)
282+
}
283+
}
284+
253285
// 1. Discover our public endpoint via beacon using a temporary UDP socket.
254286
// If -endpoint is set, skip STUN and use the fixed address (for cloud VMs).
255287
var registrationAddr string

0 commit comments

Comments
 (0)