fix(gre): не подменять неизвестные флаги состоянием туннеля

This commit is contained in:
Denozordec committed 2026-10-14 01:39:35 +07:00
1 parent 9c4a0992d7
commit 51a579d370
18 files changed
+877 -17

No files matched your search

@@ -0,0 +1,27 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import {createServer} from 'node:http'
import {randomBytes} from 'node:crypto'
const database=process.env.MMAPP_OWN_SQL_DB,url=new URL(process.env.DATABASE_URL)
assert(/^mm_accept_166_\d+$/.test(database??''));assert.equal(url.pathname,'/'+database);assert.equal(url.username,database)
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET=randomBytes(32).toString('hex');process.env.AUTH_ISSUER='https://own220.invalid';process.env.AUTH_PORTAL_URL='https://own220.invalid'
const {pool,closePool}=await import('/app/backend/dist/db/index.js'),{initDatabase}=await import('/app/backend/dist/db/bootstrap.js'),{collectGreBgpSnapshotOnce}=await import('/app/backend/dist/services/gre-bgp-snapshot-collector.js'),{buildApp}=await import('/app/backend/dist/index.js')
assert.equal((await pool.query('SELECT current_database() AS name')).rows[0].name,database);assert(Object.values((await pool.query('SELECT rolsuper,rolcreatedb,rolcreaterole,rolbypassrls FROM pg_roles WHERE rolname=current_user')).rows[0]).every(v=>v===false))
const baseline=process.env.MMAPP_BASELINE==='true',cases=[];let app,nativeCalls=0,nativeWrites=0,disabledCalls=0,flags={running:'true',disabled:'false'}
const fixture=label=>createServer((req,res)=>{nativeCalls++;if(label==='disabled')disabledCalls++;if(req.method!=='GET')nativeWrites++;res.setHeader('content-type','application/json');const gre=req.url==='/rest/interface/gre',bgp=req.url==='/rest/routing/bgp/session',row=gre?{'.id':'*1',name:'own220-gre',...(label==='second'?flags:{running:'true',disabled:'false'})}:{'.id':'*1',name:'own220-peer',established:'true','remote.as':'65220','local.as':'65221'};res.end(JSON.stringify(gre||bgp?[row]:[]))})
const natives=['first','second','disabled'].map(fixture);for(const n of natives)await new Promise(r=>n.listen(0,'127.0.0.1',r))
const report={date:'2026-10-14',block:'12.5.220',baseline,database,cases,scope:'Actual compiled GRE operational flags and actual snapshot collector, owned SQL/native HTTP fixture; signed reader/authenticated GRE API',passed:false}
try{
await initDatabase();for(let i=0;i<natives.length;i++)await pool.query("INSERT INTO servers(name,host,port,use_ssl,enabled,username,password,type) VALUES($1,'127.0.0.1',$2,false,$3,'fixture','fixture',$4)",['own220-'+i,natives[i].address().port,i<2,i===2?'home-router':'exit-node'])
app=await buildApp({logger:false,startScheduler:false});await app.ready();const token=app.jwt.sign({sub:'own220-reader',apps:['mm'],permissions:['mm:network:read'],iss:process.env.AUTH_ISSUER},{expiresIn:'5m'}),headers={authorization:'Bearer '+token};
const flagCases=[{name:'up',flags:{running:'true',disabled:'false'}},{name:'degraded',flags:{running:'false',disabled:'false'}},{name:'down',flags:{running:'false',disabled:'true'}},{name:'yes-up',flags:{running:'yes',disabled:'no'}},{name:'yes-degraded',flags:{running:'no',disabled:'no'}},{name:'yes-down',flags:{running:'no',disabled:'yes'}},{name:'missing-disabled',flags:{running:'true'}},{name:'missing-running',flags:{disabled:'false'}},{name:'blank-disabled',flags:{running:'true',disabled:''}},{name:'invalid-running',flags:{running:'junk',disabled:'false'}},{name:'invalid-disabled',flags:{running:'true',disabled:'junk'}},{name:'both-missing',flags:{}},{name:'disabled-missing-running',flags:{disabled:'true'}},{name:'disabled-invalid-running',flags:{disabled:'true',running:'junk'}},{name:'recovery',flags:{running:'true',disabled:'false'}}]
for(const item of flagCases){
flags=item.flags;const off=['true','yes'].includes(flags.disabled??''),on=['false','no'].includes(flags.disabled??''),runningKnown=['true','yes','false','no'].includes(flags.running??''),unknown=!off&&!(on&&runningKnown),before=nativeCalls,snapshot=await collectGreBgpSnapshotOnce()
assert.equal(snapshot.greWritten,unknown&&!baseline?0:2);assert.equal(snapshot.bgpWritten,2);assert.equal(snapshot.errors.length,unknown&&!baseline?1:0);assert.equal(snapshot.fatalError,undefined)
if(unknown&&!baseline){assert.match(snapshot.errors[0],/Не подтверждено состояние GRE/)}else{const row=snapshot.greTunnels.find(row=>row.targetLabel.endsWith('own220-1'));assert(row);const expected=baseline?flags.disabled==='true'?'down':flags.running==='true'?'up':'degraded':off?'down':['true','yes'].includes(flags.running??'')?'up':'degraded';assert.equal(row.status,expected)}
const api=await app.inject({method:'GET',url:'/api/gre/tunnels',headers}),body=api.json();assert.equal(api.statusCode,200);assert.equal(body.failures.length,unknown&&!baseline?1:0);assert.equal(body.tunnels.length,unknown&&!baseline?1:2);cases.push({phase:item.name,endpoint:'GRE authenticated catalog',status:200,tunnels:body.tunnels.length,failures:body.failures.length});
cases.push({phase:item.name,flags,greWritten:snapshot.greWritten,bgpWritten:2,sourceErrors:snapshot.errors.length,secondStatus:snapshot.greTunnels.find(row=>row.targetLabel.endsWith('own220-1'))?.status??null,nativeCalls:nativeCalls-before})
}
assert.equal(nativeWrites,0);assert.equal(disabledCalls,0);report.passed=true
}catch(e){report.errorName=e.name;fs.writeFileSync('/app/backend/acceptance/gre220-private.log',String(e.stack??e),{mode:0o600})}finally{if(app)await app.close();for(const n of natives){n.closeAllConnections();await new Promise(r=>n.close(r))}await closePool();report.nativeWrites=nativeWrites;report.disabledHomeRouterCalls=disabledCalls;fs.writeFileSync('/app/backend/acceptance/gre220-'+(baseline?'baseline':'fixed')+'.json',JSON.stringify(report,null,2));console.log(JSON.stringify(report))}if(!report.passed)process.exitCode=1
+1 -1
View File
@@ -5,7 +5,7 @@ import { MikrotikClient } from "./mikrotik.js"
import type { servers } from "../db/schema.js"
const server = { id: 1, name: "CHR", enabled: true } as typeof servers.$inferSelect
const raw = [{ ".id": "*g", name: "own-gre", "remote-address": "192.0.2.1", running: "true" }]
const raw = [{ ".id": "*g", name: "own-gre", "remote-address": "192.0.2.1", running: "true", disabled: "false" }]
const rows = [
{ ".id": "*1", interface: "own-gre", address: "10.1.0.1/30" },
{ ".id": "*1", interface: "own-gre", address: "fd00:151::1/64", advertise: "yes" },
+3 -3
View File
@@ -12,6 +12,7 @@ import {
type GreSnapshot,
} from "./entity-snapshots.js"
import { captureAndAppendRevision } from "./config-revisions.js"
import { readGreObservedState } from "./gre-observed-state.js"
type ServerRow = typeof servers.$inferSelect
@@ -142,8 +143,7 @@ export function mapGreLive(
ifaceAddrs.sort((a, b) => Number(Boolean(a.disabled)) - Number(Boolean(b.disabled)) || a.address.localeCompare(b.address))
const localInnerIp = ifaceAddrs[0]?.address || fromComment.localInnerIp
const secret = (g["ipsec-secret"] ?? "").trim()
const disabled = rosDisabled(g.disabled)
const running = g.running === "true" || g.running === "yes"
const { disabled, status } = readGreObservedState(g, name)
gre.push({
name,
@@ -183,7 +183,7 @@ export function mapGreLive(
allowFastPath: g["allow-fast-path"] !== "false" && g["allow-fast-path"] !== "no",
comment: readGreComment(g.comment).comment,
enabled: !disabled,
status: disabled ? "down" : running ? "up" : "degraded",
status,
})
})
@@ -0,0 +1,12 @@
import { observedBoolean } from "./ospf-observed-values.js"
/** Disabled is authoritative; an enabled interface also needs a known running flag. */
export function readGreObservedState(flags: { running?: string; disabled?: string }, name: string) {
const disabled = observedBoolean(flags.disabled)
const running = observedBoolean(flags.running)
if (disabled === null || (disabled === false && running === null)) {
throw new Error(`Не подтверждено состояние GRE ${name}`)
}
const status: "up" | "down" | "degraded" = disabled ? "down" : running ? "up" : "degraded"
return { disabled, status }
}
@@ -12,3 +12,13 @@ for(const phase of ['populated','empty','partial','failed','disabled','sync-fail
if(phase==='disabled')assert.equal(calls,0)
}
console.log('GRE alert source: populated/empty/partial/all-failed/disabled/sync error; BGP control preserved, per-device errors retained')
import {mapGreLive} from './gre-live.js'
import {mapGreAlertRow} from './gre-tunnels-live.js'
let combinations=0
for(const disabled of ['true','yes','false','no',undefined,'','junk'])for(const running of ['true','yes','false','no',undefined,'','junk']){
const row={'.id':'*1',name:'own-flags',disabled,running},off=['true','yes'].includes(disabled??''),on=['false','no'].includes(disabled??''),runningKnown=['true','yes','false','no'].includes(running??'')
if(off||on&&runningKnown){const expected=off?'down':['true','yes'].includes(running??'')?'up':'degraded';assert.equal(mapGreAlertRow(devices[0],row).status,expected);assert.equal(mapGreLive(devices[0],[row],[]).tunnels[0].status,expected)}
else {assert.throws(()=>mapGreAlertRow(devices[0],row),/Не подтверждено состояние GRE/);assert.throws(()=>mapGreLive(devices[0],[row],[]),/Не подтверждено состояние GRE/)}
combinations++
}
console.log('GRE flags: '+combinations+' combinations; true/false/yes/no, absent/blank/invalid, disabled state independent of running')
+4 -5
View File
@@ -3,6 +3,7 @@ import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js"
import { readInternetPathRows } from "./internet-path-snapshot.js"
import { readGreObservedState } from "./gre-observed-state.js"
type ServerRow = typeof servers.$inferSelect
@@ -29,10 +30,8 @@ export type GreTunnelLiveRow = {
status: GreTunnelLiveStatus
}
function mapGreRow(server: ServerRow, g: RosGre, idx: number): GreTunnelLiveRow {
const running = g.running === "true"
const disabled = g.disabled === "true"
const status: GreTunnelLiveStatus = disabled ? "down" : running ? "up" : "degraded"
export function mapGreAlertRow(server: ServerRow, g: RosGre, idx = 0): GreTunnelLiveRow {
const { status } = readGreObservedState(g, g.name ?? g[".id"] ?? "")
const sn = String(server.name ?? "").trim() || String(server.host ?? "").trim() || String(server.id)
const tn = (String(g.name ?? "").trim() || String(g[".id"] ?? "").trim() || `gre-${idx + 1}`).trim()
const label = `${tn} / ${sn}`.trim()
@@ -43,7 +42,7 @@ async function fetchGreForServer(server: ServerRow): Promise<GreTunnelLiveRow[]>
const client = MikrotikClient.fromServer(server)
const greRaw = readInternetPathRows(await client.get<unknown>("/interface/gre"), "GRE alert source", [".id", "name"])
if (new Set(greRaw.map(row => row[".id"])).size !== greRaw.length) throw new Error("Повторяющиеся ID GRE")
return greRaw.map((g, idx) => mapGreRow(server, g, idx))
return greRaw.map((g, idx) => mapGreAlertRow(server, g, idx))
}
interface GreAlertDependencies {