feat: Z34b restore-by-host + harden web nginx DNS race

Add HMAC restore-by-host for phone-less rebind; resolve gateway at request time and recreate web after stack up.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-08-07 00:16:08 +08:00
parent f0fcc1fbda
commit e571e98387
12 changed files with 492 additions and 30 deletions

View File

@@ -25,7 +25,7 @@ type Claims struct {
LocalDatabaseID string `json:"local_database_id,omitempty"`
Exp int64 `json:"exp"`
JTI string `json:"jti"`
Scope string `json:"scope,omitempty"` // sync_bind
Scope string `json:"scope,omitempty"` // sync_bind | sync_restore
YuhengUserID string `json:"yuheng_user_id,omitempty"`
}
@@ -35,6 +35,10 @@ type VerifyOpts struct {
Audience string // default aijianzhan
Now time.Time
MaxSkew time.Duration // clock skew; default 30s
// AllowEmptyPhoneZ34b restore-by-hostphone 可空(仍须 host_key+jti
AllowEmptyPhone bool
// AllowedScopes空则仅允许 sync_bind含空 scoperestore 可传 sync_bind+sync_restore。
AllowedScopes []string
}
// Sign 供联调/测试;生产由宇恒侧用同一 Secret 签发。
@@ -43,9 +47,6 @@ func Sign(secret string, c Claims) (string, error) {
if secret == "" {
return "", fmt.Errorf("ticket secret empty")
}
if c.JTI == "" || c.Phone == "" || c.HostKey == "" || c.Exp == 0 {
return "", fmt.Errorf("jti/phone/host_key/exp required")
}
if c.Iss == "" {
c.Iss = "yuheng"
}
@@ -55,6 +56,11 @@ func Sign(secret string, c Claims) (string, error) {
if c.Scope == "" {
c.Scope = "sync_bind"
}
// sync_restore允许无 phone换机仅凭宇恒账号 ID其余 scope 仍须 phone。
needPhone := c.Scope != "sync_restore"
if c.JTI == "" || c.HostKey == "" || c.Exp == 0 || (needPhone && c.Phone == "") {
return "", fmt.Errorf("jti/host_key/exp required; phone required unless scope=sync_restore")
}
raw, err := json.Marshal(c)
if err != nil {
return "", err
@@ -105,7 +111,19 @@ func Verify(ticket string, opts VerifyOpts) (*Claims, error) {
if c.Aud != aud {
return nil, fmt.Errorf("ticket audience mismatch")
}
if c.Scope != "" && c.Scope != "sync_bind" {
scope := strings.TrimSpace(c.Scope)
allowed := opts.AllowedScopes
if len(allowed) == 0 {
allowed = []string{"", "sync_bind"}
}
scopeOK := false
for _, a := range allowed {
if scope == strings.TrimSpace(a) {
scopeOK = true
break
}
}
if !scopeOK {
return nil, fmt.Errorf("ticket scope not allowed")
}
now := opts.Now
@@ -124,7 +142,10 @@ func Verify(ticket string, opts VerifyOpts) (*Claims, error) {
if exp.After(now.Add(10 * time.Minute)) {
return nil, fmt.Errorf("ticket exp too far")
}
if strings.TrimSpace(c.Phone) == "" || strings.TrimSpace(c.HostKey) == "" || strings.TrimSpace(c.JTI) == "" {
if strings.TrimSpace(c.HostKey) == "" || strings.TrimSpace(c.JTI) == "" {
return nil, fmt.Errorf("ticket missing host_key/jti")
}
if strings.TrimSpace(c.Phone) == "" && !opts.AllowEmptyPhone && scope != "sync_restore" {
return nil, fmt.Errorf("ticket missing phone/host_key/jti")
}
return &c, nil