// ==UserScript== // @name BUAA校园网保活 // @namespace local.buaa.keepalive // @version 1.0.0 // @description 默认每两小时整点检查校园网;修改crontab调整时间,账号密码在用户配置中填写。 // @crontab 0 */2 * * * // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant GM_notification // @grant GM_log // @connect gw.buaa.edu.cn // @require https://cdn.jsdelivr.net/npm/crypto-js@4.2.0/crypto-js.js // @license MIT // ==/UserScript== /* ==UserConfig== account: username: title: 校园网账号 type: text default: "" password: title: 校园网密码 type: text password: true default: "" autoLogin: title: 离线时自动登录 description: 验证码或认证失败后会自动关闭;处理完问题后可重新勾选。 type: checkbox default: true schedule: notifications: title: 重要状态变化时通知 type: checkbox default: true ==/UserConfig== */ // ScriptCat needs the returned Promise to track the entire scheduled run. return (async function () { 'use strict'; const ORIGIN = 'https://gw.buaa.edu.cn'; const ALPHABET = 'LVoJPiCN2R8G90yg+hmFHuacZ1OWMnrsSTXkYpUq/3dlbfKwv6xztjI7DeBE45QA'; const CALLBACK = 'buaaKeepaliveCallback'; const MINUTE = 60000; function request(path, params = {}) { const url = new URL(path, ORIGIN); if (url.origin !== ORIGIN) throw new Error('拒绝向非校园网地址发送请求。'); Object.entries({ ...params, _: Date.now() }).forEach(([key, value]) => { url.searchParams.set(key, String(value)); }); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url.href, timeout: 20000, redirect: path === '/' ? 'follow' : 'error', onload(response) { if (response.status !== 200) { reject(new Error('网关返回 HTTP ' + response.status + ',本次停止。')); return; } if (response.finalUrl && new URL(response.finalUrl).origin !== ORIGIN) { reject(new Error('网关跳转到其他地址,本次停止。')); return; } resolve(response.responseText); }, onerror: () => reject(new Error('无法访问校园网网关;不判定为退出登录。')), ontimeout: () => reject(new Error('校园网请求超时;不判定为退出登录。')), onabort: () => reject(new Error('校园网请求已取消。')), }); }); } function parseResponse(text) { const raw = String(text).trim(); // Parse JSONP as data only, never execute gateway responses. const match = raw.match(/^buaaKeepaliveCallback\s*\(([\s\S]*)\)\s*;?$/); let data; try { data = JSON.parse(match ? match[1] : raw); } catch { throw new Error('网关返回格式无法识别,本次不提交登录。'); } if (!data || typeof data !== 'object' || Array.isArray(data)) { throw new Error('网关返回内容不是有效对象。'); } return data; } async function api(path, params = {}, jsonp = true) { return parseResponse(await request(path, jsonp ? { callback: CALLBACK, ...params } : params)); } function pageConfig(html) { const block = html.match(/\b(?:var|let|const)\s+CONFIG\s*=\s*\{([\s\S]*?)\bportal\s*:/); if (!block) return null; const read = (key) => { const match = block[1].match(new RegExp('\\b' + key + '\\s*:\\s*["\']([^"\']*)["\']')); return match ? match[1] : ''; }; const ip = read('ip'); const acid = read('acid'); if (!/^[\da-fA-F:.]+$/.test(ip) || !/^\d+$/.test(acid)) { throw new Error('无法确定本机当前校园网 IP 或接入点,不尝试登录。'); } return { ip, acid, nas: read('nas'), mac: read('mac') }; } function refreshTarget(html, currentPath) { // Service workers have no DOMParser and do not execute HTML meta refresh. // Read only meta attributes; never evaluate inline page scripts. const decode = text => text.replace(/&(?:amp|quot|apos|lt|gt|#\d+|#x[\da-f]+);/gi, entity => { const name = entity.slice(1, -1).toLowerCase(); const named = { amp: '&', quot: '"', apos: "'", lt: '<', gt: '>' }; if (name in named) return named[name]; const value = name.startsWith('#x') ? parseInt(name.slice(2), 16) : Number(name.slice(1)); return value > 0 && value <= 0x10ffff ? String.fromCodePoint(value) : ''; }); for (const tag of html.match(/"']|"[^"]*"|'[^']*')*>/gi) || []) { const attributes = {}; const pattern = /([^\s=<>/]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g; for (const match of tag.matchAll(pattern)) { attributes[match[1].toLowerCase()] = decode(match[2] ?? match[3] ?? match[4]); } if ((attributes['http-equiv'] || '').trim().toLowerCase() !== 'refresh') continue; const refresh = (attributes.content || '').match(/^\s*\d+(?:\.\d+)?\s*;\s*url\s*=\s*(.*?)\s*$/i); if (!refresh || !refresh[1]) continue; const target = new URL(refresh[1].replace(/^(["'])([\s\S]*)\1$/, '$2'), new URL(currentPath, ORIGIN)); if (target.origin !== ORIGIN || target.username || target.password) { throw new Error('网关页面要求跳转到其他地址,已停止;不会发送账号密码。'); } if (!['/', '/srun_portal_pc', '/srun_portal_phone', '/srun_portal_success'].includes(target.pathname)) { throw new Error('网关页面跳转目标不是已知登录页面,已停止。'); } return target.pathname + target.search; } return null; } async function loadPortalConfig() { let path = '/'; const visited = new Set(); for (let hop = 0; hop < 3; hop++) { if (visited.has(path)) throw new Error('校园网页面出现循环跳转,已停止。'); visited.add(path); const html = await request(path); const config = pageConfig(html); if (config) return config; const target = refreshTarget(html, path); if (!target) throw new Error('校园网页面不含配置或可识别跳转;需要检查新的页面响应。'); GM_log('首页为自动跳转页,正在读取校园网登录页。'); path = target; } throw new Error('校园网页面跳转次数过多,已停止。'); } function stateOf(data) { if (data.error === 'ok' && typeof data.user_name === 'string' && data.user_name) return 'online'; if (data.error === 'not_online_error') return 'offline'; throw new Error('在线状态不明确,本次不提交登录。'); } // Match the gateway's _encodeUserInfo, including its byte packing. function encodeInfo(info, token) { function words(text, length) { const result = []; for (let i = 0; i < text.length; i += 4) { result.push(text.charCodeAt(i) | text.charCodeAt(i + 1) << 8 | text.charCodeAt(i + 2) << 16 | text.charCodeAt(i + 3) << 24); } if (length) result.push(text.length); return result; } const v = words(JSON.stringify(info), true); const k = words(token, false); while (k.length < 4) k.push(0); const n = v.length - 1; let z = v[n], y = v[0], sum = 0; let rounds = Math.floor(6 + 52 / (n + 1)); while (rounds-- > 0) { sum = (sum + 0x9e3779b9) | 0; const e = sum >>> 2 & 3; for (let p = 0; p <= n; p++) { y = v[p < n ? p + 1 : 0]; let m = z >>> 5 ^ y << 2; m += y >>> 3 ^ z << 4 ^ (sum ^ y); m += k[(p & 3) ^ e] ^ z; z = v[p] = (v[p] + m) | 0; } } const bytes = []; v.forEach(word => bytes.push(word & 255, word >>> 8 & 255, word >>> 16 & 255, word >>> 24 & 255)); let output = ''; for (let i = 0; i < bytes.length; i += 3) { const bits = bytes[i] << 16 | (bytes[i + 1] || 0) << 8 | (bytes[i + 2] || 0); output += ALPHABET[bits >>> 18 & 63] + ALPHABET[bits >>> 12 & 63]; output += i + 1 < bytes.length ? ALPHABET[bits >>> 6 & 63] : '='; output += i + 2 < bytes.length ? ALPHABET[bits & 63] : '='; } return '{SRBX1}' + output; } function loginParams(username, password, page, token) { const hmd5 = CryptoJS.HmacMD5(password, token).toString(); const info = encodeInfo({ username, password, ip: page.ip, acid: page.acid, enc_ver: 'srun_bx1' }, token); const checksum = [username, hmd5, page.acid, page.ip, '200', '1', info] .map(value => token + value).join(''); return { action: 'login', username, password: '{MD5}' + hmd5, os: 'Windows 10', name: 'Windows', nas_ip: page.nas, double_stack: 0, chksum: CryptoJS.SHA1(checksum).toString(), info, ac_id: page.acid, ip: page.ip, n: 200, type: 1, captchaVal: '', ap_id: '', ap_ip: '', mac: page.mac, }; } async function report(state, message, notify = false) { const previous = await GM_getValue('_status', ''); await GM_setValue('_status', state); await GM_setValue('_lastResult', new Date().toISOString() + ' ' + message); GM_log(message); if (notify && previous !== state && await GM_getValue('schedule.notifications', true)) { try { await GM_notification({ title: '北航校园网', text: message }); } catch { GM_log('通知未送达,请检查浏览器通知权限。'); } } return message; } async function pauseLogin(message) { await GM_setValue('account.autoLogin', false); return report('paused', message + ' 已关闭自动登录;请手动处理后在配置中重新开启。', true); } async function check() { // Refresh the portal first; never reuse an IP or challenge from the HAR. const page = await loadPortalConfig(); const status = await api('/cgi-bin/rad_user_info', { ip: page.ip }); if (stateOf(status) === 'online') return report('online', '已访问网关,当前处于在线状态。'); if (!await GM_getValue('account.autoLogin', true)) { return report('offline-paused', '当前未在线,自动登录已关闭。', true); } const username = String(await GM_getValue('account.username', '')).trim(); const password = String(await GM_getValue('account.password', '')); if (!username || !password) return report('unconfigured', '当前未在线,请先在用户配置中填写账号和密码。', true); const captcha = await api('/v2/srun_portal_captcha_image_info', { user_name: username, ip: page.ip }, false); if (captcha.code !== 0 || !['0', '1'].includes(String(captcha.data))) { throw new Error('验证码检查返回未知结果,不尝试登录。'); } if (String(captcha.data) === '1') return pauseLogin('网站要求输入验证码。'); const challenge = await api('/cgi-bin/get_challenge', { username, ip: page.ip }); if (challenge.error !== 'ok' || typeof challenge.challenge !== 'string' || !challenge.challenge) { throw new Error('未能获取有效的登录挑战值,不尝试登录。'); } if (challenge.client_ip && challenge.client_ip !== page.ip) { throw new Error('检测期间校园网 IP 发生变化,等待下次检查。'); } const result = await api('/cgi-bin/srun_portal', loginParams(username, password, page, challenge.challenge)); if (result.error !== 'ok') return pauseLogin('校园网认证未成功。'); // HTTP 200 is not proof of authentication; verify the online table. for (let attempt = 0; attempt < 3; attempt++) { await new Promise(resolve => setTimeout(resolve, 1000)); const after = await api('/cgi-bin/rad_user_info', { ip: page.ip }); if (stateOf(after) === 'online') { if (after.user_name + (after.domain ? '@' + after.domain : '') !== username && after.user_name !== username) { return pauseLogin('查询到的在线账号与配置不一致,不再自动认证。'); } await request('/srun_portal_success', { ac_id: page.acid, theme: 'buaa' }); return report('reconnected', '校园网已重新登录,并确认在线。', true); } } return pauseLogin('提交认证后仍未确认在线。'); } // Scheduling belongs exclusively to @crontab; every invocation checks once. const now = Date.now(); const lock = Number(await GM_getValue('_runningSince', 0)); if (lock > 0 && now >= lock && now - lock < 4 * MINUTE) return '已有检查正在执行。'; await GM_setValue('_runningSince', now); try { return await check(); } catch (error) { // Never log request URLs, response bodies or credential material. const safe = error instanceof Error && !/https?:|password|username/i.test(error.message) ? error.message : '检查未完成,请查看网络和脚本权限设置。'; return await report('check-failed', safe, true); } finally { await GM_setValue('_runningSince', 0); } })();