fix(node): clear consumed invitations after confirmed pairing
This commit is contained in:
@@ -81,6 +81,13 @@ func OpenPairing(store *Store, dir, version string, inventory func() Inventory)
|
|||||||
if (p.state.Phase == "inviting" || p.state.Phase == "pending") && p.state.Invitation == nil {
|
if (p.state.Phase == "inviting" || p.state.Phase == "pending") && p.state.Invitation == nil {
|
||||||
return nil, errors.New("incomplete invitation")
|
return nil, errors.New("incomplete invitation")
|
||||||
}
|
}
|
||||||
|
if (p.state.Phase == "paired" || p.state.Phase == "revoked") && p.state.Invitation != nil {
|
||||||
|
next := p.state
|
||||||
|
next.Invitation = nil
|
||||||
|
if e := p.save(next); e != nil {
|
||||||
|
return nil, e
|
||||||
|
}
|
||||||
|
}
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
func savePrivateJSON(path string, value any) error {
|
func savePrivateJSON(path string, value any) error {
|
||||||
@@ -186,6 +193,8 @@ func (p *Pairing) invite(address string) (map[string]any, error) {
|
|||||||
if e := p.save(PairState{Schema: PairSchema, Phase: "inviting", Invitation: i, Revocations: p.state.Revocations}); e != nil {
|
if e := p.save(PairState{Schema: PairSchema, Phase: "inviting", Invitation: i, Revocations: p.state.Revocations}); e != nil {
|
||||||
return nil, e
|
return nil, e
|
||||||
}
|
}
|
||||||
|
p.connection = "offline"
|
||||||
|
p.lastSeen = 0
|
||||||
id, _ := p.store.Public()
|
id, _ := p.store.Public()
|
||||||
code, _ := json.Marshal(map[string]any{"schema": PairSchema, "node_id": id, "id": i.ID, "endpoint": i.Endpoint, "expires_at": i.ExpiresAt, "secret": secret})
|
code, _ := json.Marshal(map[string]any{"schema": PairSchema, "node_id": id, "id": i.ID, "endpoint": i.Endpoint, "expires_at": i.ExpiresAt, "secret": secret})
|
||||||
return map[string]any{"code": "MCN1." + base64.RawURLEncoding.EncodeToString(code), "expires_at": i.ExpiresAt}, nil
|
return map[string]any{"code": "MCN1." + base64.RawURLEncoding.EncodeToString(code), "expires_at": i.ExpiresAt}, nil
|
||||||
|
|||||||
@@ -103,6 +103,9 @@ func TestPairingDurableCommitConflictAndReplay(t *testing.T) {
|
|||||||
if _, e = restored.invite("192.168.10.4"); e == nil {
|
if _, e = restored.invite("192.168.10.4"); e == nil {
|
||||||
t.Fatal("paired Node offered another invitation")
|
t.Fatal("paired Node offered another invitation")
|
||||||
}
|
}
|
||||||
|
if restored.state.Invitation != nil {
|
||||||
|
t.Fatal("consumed invitation retained")
|
||||||
|
}
|
||||||
raw, _ := json.Marshal(restored.status())
|
raw, _ := json.Marshal(restored.status())
|
||||||
if strings.Contains(string(raw), i["secret"].(string)) || strings.Contains(string(raw), "client_pem") {
|
if strings.Contains(string(raw), i["secret"].(string)) || strings.Contains(string(raw), "client_pem") {
|
||||||
t.Fatal("status leaked trust")
|
t.Fatal("status leaked trust")
|
||||||
|
|||||||
@@ -138,6 +138,7 @@ func (p *Pairing) remoteHandler() http.Handler {
|
|||||||
}
|
}
|
||||||
next := p.state
|
next := p.state
|
||||||
next.Phase = "paired"
|
next.Phase = "paired"
|
||||||
|
next.Invitation = nil
|
||||||
if p.save(next) != nil {
|
if p.save(next) != nil {
|
||||||
http.Error(w, "State unavailable", 503)
|
http.Error(w, "State unavailable", 503)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import tarfile
|
|||||||
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
VERSION = "0.5.0"
|
VERSION = "0.5.1"
|
||||||
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
|
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function CoreConnectionView({ failure }: { failure: (error: unknown) => v
|
|||||||
<Select label="Адрес БК для подключения" value={address} options={value.addresses.map(item => ({ value: item, label: item }))} onChange={setAddress} disabled={pending || !available} />
|
<Select label="Адрес БК для подключения" value={address} options={value.addresses.map(item => ({ value: item, label: item }))} onChange={setAddress} disabled={pending || !available} />
|
||||||
{value.addresses.length === 0 && <p className="node-note">Подключите БК к частной сети. Доступные адреса появятся автоматически.</p>}
|
{value.addresses.length === 0 && <p className="node-note">Подключите БК к частной сети. Доступные адреса появятся автоматически.</p>}
|
||||||
<Button disabled={pending || !available || !address} onClick={() => void create()}>{pending ? "Создаём…" : value.phase === "inviting" ? "Создать новое приглашение" : "Создать приглашение"}</Button>
|
<Button disabled={pending || !available || !address} onClick={() => void create()}>{pending ? "Создаём…" : value.phase === "inviting" ? "Создать новое приглашение" : "Создать приглашение"}</Button>
|
||||||
{value.invitation && <p className="node-note">Действует до {new Date(value.invitation.expires_at * 1000).toLocaleTimeString("ru-RU")}. Приглашение позволяет одному Core получить доверие этого БК. Передавайте его только нужному оператору.</p>}
|
{value.phase === "inviting" && value.invitation && <p className="node-note">Действует до {new Date(value.invitation.expires_at * 1000).toLocaleTimeString("ru-RU")}. Приглашение позволяет одному Core получить доверие этого БК. Передавайте его только нужному оператору.</p>}
|
||||||
{code && <><TextAreaField label="Код приглашения" value={code} readOnly rows={5} spellCheck={false} onFocus={event => event.target.select()} /><Button onClick={async () => { try { await navigator.clipboard.writeText(code); setNotice("Код скопирован"); } catch { setNotice("Выделите код в поле и скопируйте его сочетанием Ctrl+C."); } }}>Скопировать код</Button></>}
|
{code && <><TextAreaField label="Код приглашения" value={code} readOnly rows={5} spellCheck={false} onFocus={event => event.target.select()} /><Button onClick={async () => { try { await navigator.clipboard.writeText(code); setNotice("Код скопирован"); } catch { setNotice("Выделите код в поле и скопируйте его сочетанием Ctrl+C."); } }}>Скопировать код</Button></>}
|
||||||
{value.phase === "inviting" && !code && <p className="node-note">Код показывается только при создании. Создайте новое приглашение, если он не сохранился у вас.</p>}
|
{value.phase === "inviting" && !code && <p className="node-note">Код показывается только при создании. Создайте новое приглашение, если он не сохранился у вас.</p>}
|
||||||
</div>}
|
</div>}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Привязка БК к Mission Core — протокол v1
|
# Привязка БК к Mission Core — протокол v1
|
||||||
|
|
||||||
Дата: 05.09.2026. Инкремент Node 0.5.0. Основание: MISSIONCOR-76 и согласованная
|
Дата: 05.09.2026. Инкремент Node 0.5.0; исправление отображения и очистки приглашения 0.5.1. Основание: MISSIONCOR-76 и согласованная
|
||||||
поверхность `03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md`. Чистая установка ОС
|
поверхность `03_SYSTEM_AND_VEHICLE_PAIRING_SURFACE.md`. Чистая установка ОС
|
||||||
отложена владельцем; она не закрыта текущими проверками и не блокирует этот этап.
|
отложена владельцем; она не закрыта текущими проверками и не блокирует этот этап.
|
||||||
|
|
||||||
@@ -153,3 +153,28 @@ Bootstrap ограничен 16 одновременными соединени
|
|||||||
автономные миссии. Следующий отдельный инкремент: реальный RealSense D455 за Node;
|
автономные миссии. Следующий отдельный инкремент: реальный RealSense D455 за Node;
|
||||||
после него K1 через Wi-Fi Bridge. Их полномочия и API добавляются к существующим
|
после него K1 через Wi-Fi Bridge. Их полномочия и API добавляются к существующим
|
||||||
границам Node/Core, а не к SSH или UI браузера.
|
границам Node/Core, а не к SSH или UI браузера.
|
||||||
|
|
||||||
|
|
||||||
|
## Фактическая проверка 05.09.2026
|
||||||
|
|
||||||
|
На тестовом Mini с установленным 0.5.0 через UI созданы приглашение и аппарат,
|
||||||
|
подтверждены идентичность, исходящий mTLS и живые сведения БК. Через UI Core
|
||||||
|
проверены список, глаз конфигурации, отзыв доверия; через UI Node — получение
|
||||||
|
отзыва и создание нового приглашения. Повторная привязка вернула тот же аппарат
|
||||||
|
в сеть без второй строки. Управление датчиками при этом не запускалось.
|
||||||
|
|
||||||
|
Для проверки отказа служба Node была кратко остановлена инженерным SSH:
|
||||||
|
интерфейс Core показал «Нет связи»; после запуска служба сама восстановила пару
|
||||||
|
и UI показал «В сети». Это проверка сохранения пары после перезапуска службы,
|
||||||
|
не квалификация чистой ОС или полного холодного старта Mini.
|
||||||
|
|
||||||
|
GUI-прогон использовал реальные интерфейсы Core и Node (к последнему — временный
|
||||||
|
loopback-туннель и штатный одноразовый вход). Встроенное desktop-окно Node 0.5.0
|
||||||
|
также запущено на Mini. Туннель относится только к инженерному управлению UI;
|
||||||
|
сам канал Node → Core работал непосредственно через частную LAN с mTLS.
|
||||||
|
|
||||||
|
Выявленная при GUI-проверке неточность: после отзыва показывался срок уже
|
||||||
|
использованного приглашения. В 0.5.1 приглашение удаляется при commit и при
|
||||||
|
чтении старого paired/revoked-состояния; его срок UI показывает только пока оно
|
||||||
|
открыто. Новое приглашение сбрасывает время связи предыдущей пары. Исправление
|
||||||
|
внесено в пакет и регрессионный тест, а не в настройки тестовой ОС.
|
||||||
|
|||||||
Reference in New Issue
Block a user