Телеметрия: раз в 6 часов сверяет версию десктопного MAX из его apt-репозитория с той, которой представляется мост (src/max/client.ts на GitHub), и один раз на каждую новую версию MAX пишет в тему автоотчётов — заранее, до «Приложение устарело»

This commit is contained in:
Folist 2026-10-12 18:52:57 +03:00
parent 1eda70b286
commit d58f8a0b54
6 changed files with 200 additions and 3 deletions

View file

@ -1,6 +1,6 @@
FROM node:22-alpine
WORKDIR /app
COPY server.mjs reports.mjs site.mjs ./
COPY server.mjs reports.mjs site.mjs maxversion.mjs ./
ENV PORT=3100
EXPOSE 3100
CMD ["node", "server.mjs"]

View file

@ -85,3 +85,15 @@ unique visitors are a per-day set of salted hashes kept in memory with a salt th
Moscow day, so `data/site.json` holds numbers only. Countries come from the free DB-IP «IP to Country
Lite» table (CC BY 4.0 — the `/stats/` page credits it), which the server downloads into
`data/dbip-country-lite.csv.gz` on start and refreshes monthly; until it loads, countries read «??».
## MAX desktop version watch (`maxversion.mjs`)
MAX refuses logins from a client it considers outdated («Приложение устарело, пожалуйста, обновитесь»),
and the bridge presents itself as MAX desktop with `DEFAULT_APP_VERSION` / `DEFAULT_BUILD_NUMBER` from
`src/max/client.ts`. Every 6 hours (and a minute after start) the server reads MAX's apt repo
(`download.max.ru/linux/deb/…/Packages`: the newest «Version:» and the build in «MAX-<version>.<build>.deb»)
and the bridge's numbers from `src/max/client.ts` on GitHub `main`, so it follows every release by itself.
When MAX is ahead it posts ONE notice per new MAX version to the maintainer's «🤖 Автоотчёты об ошибках»
topic, with the numbers to release (and the `.env` override users can apply meanwhile). State, including
the last announced version, is kept in `data/maxversion.json`; if GitHub is unreachable it compares with the
bridge numbers it read last time.

View file

@ -0,0 +1,13 @@
// Types for maxversion.mjs — only so the vitest suite in tests/ type-checks (tsc --noEmit).
export interface ClientVersion {
version: string;
build: number | null;
}
export const MAX_PACKAGES_URL: string;
export const BRIDGE_CLIENT_URL: string;
export function compareVersions(a: string, b: string): -1 | 0 | 1;
export function parsePackages(text: string): ClientVersion[];
export function newestPackage(list: ClientVersion[]): ClientVersion | null;
export function parseBridgeDefaults(source: string): ClientVersion | null;
export function shouldNotify(maxLatest: ClientVersion | null, bridge: ClientVersion | null, lastNotified: string | null | undefined): boolean;
export function formatVersionNotice(maxLatest: ClientVersion, bridge: ClientVersion): string;

View file

@ -0,0 +1,67 @@
// Early warning for «Приложение устарело»: MAX refuses logins from a client older than it accepts,
// and the bridge presents itself as MAX desktop with a version baked into src/max/client.ts. On
// 2026-10-12 every new /login broke a day after MAX shipped desktop 26.35.x — this check would have
// said so the day before. Twice a day the server compares MAX's newest desktop package with what
// the bridge presents and tells the maintainer once per new MAX version.
// The decisions live here (pure, unit-tested); server.mjs does the fetching and the posting.
/** MAX's own apt repo — the same pair the desktop installer carries (version + build in the file name). */
export const MAX_PACKAGES_URL = 'https://download.max.ru/linux/deb/dists/stable/main/binary-amd64/Packages';
/** What the bridge presents, read from its source on main (follows every release by itself). */
export const BRIDGE_CLIENT_URL = 'https://raw.githubusercontent.com/Trollobot/Telemax/main/src/max/client.ts';
/** «26.35.1» vs «26.9.0» → numeric, part by part. */
export function compareVersions(a, b) {
const pa = String(a).split('.').map(Number);
const pb = String(b).split('.').map(Number);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d) return d < 0 ? -1 : 1;
}
return 0;
}
/** Debian Packages text → [{version, build}] (build from «MAX-<version>.<build>.deb», null if absent). */
export function parsePackages(text) {
const out = [];
for (const block of String(text).split(/\r?\n\r?\n/)) {
const version = /^Version:\s*(\d+\.\d+\.\d+)\s*$/m.exec(block)?.[1];
if (!version) continue;
const build = /^Filename:.*?-\d+\.\d+\.\d+\.(\d+)\.deb\s*$/m.exec(block)?.[1];
out.push({ version, build: build ? Number(build) : null });
}
return out;
}
/** The newest package (by version, then build); null for an empty list. */
export function newestPackage(list) {
return list.reduce((best, p) => {
if (!best) return p;
const c = compareVersions(p.version, best.version);
return c > 0 || (c === 0 && (p.build ?? 0) > (best.build ?? 0)) ? p : best;
}, null);
}
/** The bridge's DEFAULT_APP_VERSION / DEFAULT_BUILD_NUMBER from src/max/client.ts; null when not found. */
export function parseBridgeDefaults(source) {
const version = /DEFAULT_APP_VERSION\s*=\s*['"`](\d+\.\d+\.\d+)['"`]/.exec(String(source))?.[1];
if (!version) return null;
const build = /DEFAULT_BUILD_NUMBER\s*=\s*(\d+)/.exec(String(source))?.[1];
return { version, build: build ? Number(build) : null };
}
/** Notify when MAX is ahead of the bridge and this MAX version hasn't been announced yet. */
export function shouldNotify(maxLatest, bridge, lastNotified) {
if (!maxLatest || !bridge) return false;
if (compareVersions(maxLatest.version, bridge.version) <= 0) return false;
return lastNotified !== maxLatest.version;
}
export function formatVersionNotice(maxLatest, bridge) {
const build = maxLatest.build != null ? ` (сборка ${maxLatest.build})` : '';
return (
`🆕 MAX выпустил десктоп ${maxLatest.version}${build}, а мост представляется ${bridge.version}${bridge.build != null ? ` (${bridge.build})` : ''}.\n\n` +
`Если /login начнёт отвечать «Приложение устарело» — выпустить обновление с этими номерами: DEFAULT_APP_VERSION и DEFAULT_BUILD_NUMBER в src/max/client.ts. ` +
`Пользователи до релиза могут обойтись .env: MAX_APP_VERSION=${maxLatest.version}${maxLatest.build != null ? `, MAX_BUILD_NUMBER=${maxLatest.build}` : ''}.`
);
}

View file

@ -13,6 +13,7 @@ import { createInterface } from 'node:readline';
import path from 'node:path';
import { sanitizeReport, acceptReport, recordReport, scheduleNotice, dueIn, formatNotice, listReports } from './reports.mjs';
import { createSiteStats, summarize, createGeoBuilder, cleanIp } from './site.mjs';
import { MAX_PACKAGES_URL, BRIDGE_CLIENT_URL, parsePackages, newestPackage, parseBridgeDefaults, shouldNotify, formatVersionNotice } from './maxversion.mjs';
const PORT = Number(process.env.PORT || 3100);
const DATA_FILE = process.env.DATA_FILE || '/data/installs.json';
@ -35,6 +36,9 @@ const SITE_FILE = process.env.SITE_FILE || path.join(path.dirname(DATA_FILE), 's
const GEO_FILE = process.env.GEO_FILE || path.join(path.dirname(DATA_FILE), 'dbip-country-lite.csv.gz');
const GEO_MAX_AGE_MS = 35 * 86_400_000; // DB-IP publishes monthly
const HIT_LIMIT_PER_IP = 120; // per rolling hour
// MAX desktop version watch: what was last seen and announced (maxversion.mjs).
const MAXVERSION_FILE = process.env.MAXVERSION_FILE || path.join(path.dirname(DATA_FILE), 'maxversion.json');
const MAXVERSION_EVERY_MS = 6 * 3600_000;
/** installId -> { firstSeen, lastSeen, version } */
let db = {};
@ -83,6 +87,11 @@ async function load() {
} catch {
// no visits yet
}
try {
maxVersionState = { ...maxVersionState, ...JSON.parse(await readFile(MAXVERSION_FILE, 'utf8')) };
} catch {
// never checked yet
}
}
/** Downloads this month's (or last month's) DB-IP Lite country table when ours is missing or stale, then loads it. */
@ -183,7 +192,13 @@ async function notifyMaintainer(sig) {
}
const entries = due.map((s) => reports.signatures[s]).filter(Boolean);
if (!entries.length) return;
const text = formatNotice(entries);
await sendToMaintainer(formatNotice(entries));
}
/** Posts into the maintainer's «🤖 Автоотчёты об ошибках» topic (created once). False when it couldn't. */
async function sendToMaintainer(text) {
if (!REPORT_BOT_TOKEN || !REPORT_CHAT_ID) return false;
let ok = true;
try {
let thread = REPORT_THREAD_ID || reports.notify.threadId;
if (!thread) {
@ -198,9 +213,36 @@ async function notifyMaintainer(sig) {
return tg('sendMessage', { text });
});
} catch (err) {
console.log(`report notice failed: ${err.message}`);
ok = false;
console.log(`maintainer notice failed: ${err.message}`);
}
void save(REPORTS_FILE, reports);
return ok;
}
// --- MAX desktop version watch (maxversion.mjs) -----------------------------------------------
/** { bridge: {version, build}, maxLatest, lastNotified, checkedAt } — bridge is the last one read from GitHub. */
let maxVersionState = { bridge: null, maxLatest: null, lastNotified: null, checkedAt: 0 };
async function checkMaxVersion() {
try {
const res = await fetch(MAX_PACKAGES_URL, { signal: AbortSignal.timeout(30_000) });
if (!res.ok) throw new Error(`MAX repo HTTP ${res.status}`);
const maxLatest = newestPackage(parsePackages(await res.text()));
// GitHub unreachable: keep comparing with what the bridge presented last time.
const src = await fetch(BRIDGE_CLIENT_URL, { signal: AbortSignal.timeout(30_000) })
.then((r) => (r.ok ? r.text() : null))
.catch(() => null);
const bridge = (src && parseBridgeDefaults(src)) || maxVersionState.bridge;
console.log(`max version: MAX desktop ${maxLatest?.version ?? '?'} (${maxLatest?.build ?? '?'}), bridge presents ${bridge?.version ?? '?'}`);
if (shouldNotify(maxLatest, bridge, maxVersionState.lastNotified) && (await sendToMaintainer(formatVersionNotice(maxLatest, bridge)))) {
maxVersionState.lastNotified = maxLatest.version;
}
maxVersionState = { ...maxVersionState, bridge, maxLatest, checkedAt: Date.now() };
void save(MAXVERSION_FILE, maxVersionState);
} catch (err) {
console.log(`max version check failed: ${err.message}`);
}
}
/** /stats and /reports: HTTPS-only + STATS_KEY. Answers 403 itself and returns false when refused. */
@ -350,4 +392,6 @@ setInterval(() => {
void refreshGeo();
site.prune(Date.now());
}, 86_400_000).unref();
setTimeout(() => void checkMaxVersion(), 60_000).unref();
setInterval(() => void checkMaxVersion(), MAXVERSION_EVERY_MS).unref();
server.listen(PORT, () => console.log(`telemetry listening on :${PORT} (data: ${DATA_FILE})`));

61
tests/maxversion.test.ts Normal file
View file

@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { compareVersions, formatVersionNotice, newestPackage, parseBridgeDefaults, parsePackages, shouldNotify } from '../telemetry-server/maxversion.mjs';
// The telemetry server's early warning for «Приложение устарело» (telemetry-server/maxversion.mjs).
// The shape of MAX's apt repo Packages file on 2026-10-12 (trimmed).
const PACKAGES = [
'Package: max',
'Version: 26.35.1',
'Architecture: amd64',
'Filename: pool/main/m/max/MAX-26.35.1.80662.deb',
'Size: 311163278',
'',
'Package: max',
'Version: 26.35.0',
'Filename: pool/main/m/max/MAX-26.35.0.80520.deb',
'',
'Package: max',
'Version: 26.9.0',
'Filename: pool/main/m/max/MAX-26.9.0.70001.deb',
'',
].join('\n');
describe('MAX desktop version watch', () => {
it('compares versions numerically', () => {
expect(compareVersions('26.35.1', '26.9.0')).toBe(1);
expect(compareVersions('26.24.0', '26.35.1')).toBe(-1);
expect(compareVersions('26.35.1', '26.35.1')).toBe(0);
});
it('reads the repo and picks the newest package with its build', () => {
const list = parsePackages(PACKAGES);
expect(list).toHaveLength(3);
expect(newestPackage(list)).toEqual({ version: '26.35.1', build: 80662 });
expect(newestPackage([])).toBeNull();
expect(parsePackages('garbage\n\nVersion: x')).toEqual([]);
});
it('reads what the bridge presents from its source', () => {
const src = "const DEFAULT_APP_VERSION = '26.35.1';\nconst DEFAULT_BUILD_NUMBER = 80662;";
expect(parseBridgeDefaults(src)).toEqual({ version: '26.35.1', build: 80662 });
expect(parseBridgeDefaults('nothing here')).toBeNull();
});
it('notifies once per newer MAX version, never when the bridge is current', () => {
const bridge = { version: '26.35.1', build: 80662 };
expect(shouldNotify({ version: '26.35.1', build: 80662 }, bridge, null)).toBe(false);
expect(shouldNotify({ version: '26.36.0', build: 81000 }, bridge, null)).toBe(true);
expect(shouldNotify({ version: '26.36.0', build: 81000 }, bridge, '26.36.0')).toBe(false);
expect(shouldNotify({ version: '26.37.0', build: 81500 }, bridge, '26.36.0')).toBe(true);
expect(shouldNotify(null, bridge, null)).toBe(false);
expect(shouldNotify({ version: '26.36.0', build: 1 }, null, null)).toBe(false);
});
it('tells the maintainer the exact numbers to use', () => {
const text = formatVersionNotice({ version: '26.36.0', build: 81000 }, { version: '26.35.1', build: 80662 });
expect(text).toContain('26.36.0 (сборка 81000)');
expect(text).toContain('MAX_APP_VERSION=26.36.0, MAX_BUILD_NUMBER=81000');
expect(text).toContain('DEFAULT_APP_VERSION');
});
});