Sleepstars 的记录室

Sleepstars 的记录室

深大校园网 Linux 自动认证:Srun 协议解析与 Python 实现

2026-09-05

协议分析

Srun Portal 的所有接口都是 JSONP 风格的 GET 请求:参数放在 query string,附加 callback=jsonp,响应体为 jsonp({...}),截取括号内 JSON 解析即可。

认证流程:查在线 → 取真实 ac_id → 取挑战值 → 登录。另外还有一个独立的注销接口。

第 0 步:检查在线状态

GET /cgi-bin/rad_user_info?ip=<本机IP>&callback=jsonp

在线时返回:

{
  "error": "ok",
  "user_name": "2023001",
  "online_ip": "10.20.30.40",
  "products_name": "学生区城市热点",
  "user_balance": 0,
  "online_device_total": 1
}

error == "ok" 即在线,直接退出,无需后续流程。

第 1 步:获取真实的 ac_id(关键坑)

Portal 页面 URL 里的 ac_id(如 ?ac_id=1)只是入口主题 ID,并不是认证用的 acid。真正的 acid 藏在 Portal 页面内嵌的 JS 配置里:

GET /srun_portal_pc?ac_id=1&theme=proyx

返回的 HTML 中有一段:

var CONFIG = {
    page   : 'account',
    acid   : "12",          // ← 这才是认证用的 acid
    ip     : "10.20.30.40",
    ...
    portal : {"AuthIP":"", "ServiceIP":"https://net.szu.edu.cn:8800", ...}
};

用正则 acid\s*:\s*"(\d+)" 提取即可。用 URL 里的 ac_id 直接认证会报 Unknow ac-type: 错误——只有一个例外:已在线时重复登录会提前短路返回 ip_already_online_error,掩盖这个错误,容易误以为 ac_id 没问题。

第 2 步:获取挑战值 token

GET /cgi-bin/get_challenge?username=<账号>&ip=<本机IP>&callback=jsonp

返回:

{
  "error": "ok",
  "challenge": "a1b2c3d4..."   // 40 位 hex
}

这个 challenge(下称 token)是后续所有加密的密钥,每次请求都会变化,因此无法重放。

第 3 步:构造登录请求

核心步骤,需要构造三个加密字段。

① password 字段——HMAC-MD5

password = "{MD5}" + HMAC_MD5(key=token, msg=明文密码).hexdigest()

注意是直接以 token 为密钥对明文密码做 HMAC,不是先 MD5 再 HMAC。

② info 字段——xEncode + 自定义 Base64

先构造一个 JSON(字段顺序固定):

{"username":"2023001","password":"明文密码","ip":"10.20.30.40","acid":"12","enc_ver":"srun_bx1"}

然后以 token 为密钥做 xEncode(XXTEA 变体,delta 为 0x9E3779B9),再做一次字母表置换过的 Base64

标准字母表: ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/
置换后:     LVoJPiCN2R8G90yg+hmFHuacZ1OWMnrsSTXkYpUq/3dlbfKwv6xztjI7DeBE45QA

最终:

info = "{SRBX1}" + customBase64(xEncode(jsonString, token))

xEncode 伪代码:

s(str, withLen): 每 4 字节按小端打包成 uint32 数组;withLen 时末尾追加原始长度
k = s(token, false),不足 4 个 word 补 0
v = s(plain, true); n = len(v)-1; z = v[n]
q = 6 + 52 // (n+1); d = 0
重复 q 次:
    d = (d + 0x9E3779B9) & 0xFFFFFFFF
    e = (d >>> 2) & 3
    for p in 0..n-1:
        y = v[p+1]
        m = (z>>>5 ^ y<<2) + ((y>>>3 ^ z<<4) ^ (d^y)) + (k[(p&3)^e] ^ z)
        z = v[p] = (v[p]+m) & 0xFFFFFFFF
    y = v[0]
    m = (z>>>5 ^ y<<2) + ((y>>>3 ^ z<<4) ^ (d^y)) + (k[(n&3)^e] ^ z)
    z = v[n] = (v[n]+m) & 0xFFFFFFFF
输出: 每个 word 小端拆 4 字节拼接

③ chksum 字段——SHA1 拼接签名

按固定顺序把 7 段 token+字段 拼接后计算 SHA1:

chksum = SHA1(
    token+username +
    token+hmacPassword +
    token+acid +
    token+ip +
    token+"200" +      // n=200
    token+"0" +        // type=0(桌面客户端)
    token+info         // 上面构造的完整 info 字符串(含 {SRBX1} 前缀)
).hexdigest()

第 4 步:发起登录

GET /cgi-bin/srun_portal?action=login&username=<账号>&password={MD5}...&os=Linux&name=linux&double_stack=0&chksum=<sha1>&info={SRBX1}...&ac_id=12&ip=<本机IP>&n=200&type=0&callback=jsonp

成功返回:

{
  "error": "ok",
  "ecode": 0,
  "suc_msg": "login_ok",
  "ploy_msg": "E0000: Login is successful.",
  "access_token": "e479d16c..."
}

注销(踢下线)

sign = SHA1(time + username + ip + unbind + time)
GET /cgi-bin/rad_user_dm?ip=<本机IP>&username=<账号>&time=<unix秒>&unbind=1&sign=<sha1>&callback=jsonp

error == "ok" 即注销成功,本机会话立即下线。已在线时重复调登录接口,服务器返回 error:"ok", suc_msg:"ip_already_online_error"——有些官方客户端此时会自动调注销接口踢掉旧会话再重登,本文脚本默认不这么做(在线即退出),注销需要显式加 --logout

任何加密字段构造错误,服务器都会返回 sign/decrypt 类错误而非 ok,所以 error:"ok" 本身就是协议正确性的强校验。

完整代码

纯 Python 3 标准库,零依赖。保存为 srun_auth.py

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Srun (深澜) campus portal auth client.

Protocol:
  0. GET /srun_portal_pc?ac_id=1&theme=proyx -> parse real acid from CONFIG
  1. GET /cgi-bin/rad_user_info              -> error == "ok" means online
  2. GET /cgi-bin/get_challenge              -> token = res["challenge"]
  3. password = "{MD5}" + HMAC-MD5(key=token, msg=password).hexdigest()
  4. info     = "{SRBX1}" + base64_custom(xEncode(json, token))
  5. chksum   = SHA1(token+username + token+hmacpwd + token+acid + token+ip
                     + token+"200" + token+"0" + token+info).hexdigest()
  6. GET /cgi-bin/srun_portal?action=login&...

  Logout: GET /cgi-bin/rad_user_dm with sign=SHA1(time+username+ip+unbind+time)

All requests are JSONP style: ?callback=jsonp, body = "jsonp({...})".
Only Python standard library is used.
"""

import argparse
import base64
import hashlib
import hmac as hmac_mod
import json
import platform
import re
import socket
import ssl
import sys
import time
import urllib.parse
import urllib.request

# Custom base64 alphabet used by the portal.
B64_ALPHA = "LVoJPiCN2R8G90yg+hmFHuacZ1OWMnrsSTXkYpUq/3dlbfKwv6xztjI7DeBE45QA"
_B64_TRANS = str.maketrans(
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", B64_ALPHA
)

# xEncode constants: XXTEA delta and 32-bit mask.
_DELTA = 0x9E3779B9
_MASK = 0xFFFFFFFF


def _u32(x):
    return x & _MASK


def _str_to_words(data, include_len):
    """Pack 4 bytes per uint32 (little-endian), optionally append length."""
    v = []
    for i in range(0, len(data), 4):
        v.append(int.from_bytes(data[i:i + 4].ljust(4, b"\x00"), "little"))
    if include_len:
        v.append(len(data))
    return v


def xencode(data, key):
    """XXTEA-style xEncode. Bytes in, bytes out."""
    if not data:
        return b""
    v = _str_to_words(data, True)
    k = _str_to_words(key, False)
    while len(k) < 4:
        k.append(0)
    n = len(v) - 1
    z = v[n]
    d = 0
    q = 6 + 52 // (n + 1)
    while q > 0:
        q -= 1
        d = _u32(d + _DELTA)
        e = (d >> 2) & 3
        for p in range(n):
            y = v[p + 1]
            m = (z >> 5) ^ _u32(y << 2)
            m = _u32(m + (((y >> 3) ^ _u32(z << 4)) ^ (d ^ y)))
            m = _u32(m + (k[(p & 3) ^ e] ^ z))
            v[p] = z = _u32(v[p] + m)
        y = v[0]
        m = (z >> 5) ^ _u32(y << 2)
        m = _u32(m + (((y >> 3) ^ _u32(z << 4)) ^ (d ^ y)))
        m = _u32(m + (k[(n & 3) ^ e] ^ z))
        v[n] = z = _u32(v[n] + m)
    return b"".join(w.to_bytes(4, "little") for w in v)


def encode_user_info(info, token):
    """'{SRBX1}' + customBase64(xEncode(JSON.stringify(info), token))"""
    # Compact JSON, no spaces; key order = insertion order.
    payload = json.dumps(info, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
    enc = xencode(payload, token.encode("utf-8"))
    b64 = base64.b64encode(enc).decode("ascii").translate(_B64_TRANS)
    return "{SRBX1}" + b64


def hmac_md5_hex(msg, key):
    return hmac_mod.new(key.encode("utf-8"), msg.encode("utf-8"), hashlib.md5).hexdigest()


def sha1_hex(s):
    return hashlib.sha1(s.encode("utf-8")).hexdigest()


class SrunPortal:
    def __init__(self, host, ac_id, username, password, ip=None, timeout=10):
        self.host = host.rstrip("/")
        self.ac_id = int(ac_id)
        self.username = username
        self.password = password
        self.ip = ip or self._local_ip()
        self.timeout = timeout
        # The campus portal cert is not always chain-valid on minimal
        # systems, so don't verify TLS.
        self._ssl_ctx = ssl.create_default_context()
        self._ssl_ctx.check_hostname = False
        self._ssl_ctx.verify_mode = ssl.CERT_NONE

    def _local_ip(self):
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        try:
            s.connect((urllib.parse.urlparse(self.host).hostname, 80))
            return s.getsockname()[0]
        finally:
            s.close()

    def _jsonp(self, path, params=None):
        q = dict(params or {})
        q["callback"] = "jsonp"
        url = self.host + path + "?" + urllib.parse.urlencode(q)
        req = urllib.request.Request(url, headers={"User-Agent": "srun-portal-cli/1.0"})
        with urllib.request.urlopen(req, timeout=self.timeout, context=self._ssl_ctx) as r:
            text = r.read().decode("utf-8", "replace")
        # unwrap jsonp({...}) (tolerate plain JSON too)
        start = text.find("(")
        end = text.rfind(")")
        if start != -1 and end > start:
            text = text[start + 1:end]
        return json.loads(text)

    def check_online(self):
        """Return (online: bool, info: dict)."""
        res = self._jsonp("/cgi-bin/rad_user_info", {"ip": self.ip})
        return res.get("error") == "ok", res

    def get_real_acid(self):
        """Fetch the portal page and parse the real acid from its embedded CONFIG.

        The ac_id in the portal URL (e.g. 1) is only an entry theme id; the
        server embeds the actual auth acid in a JS CONFIG block, e.g.
        `acid : "12"`. Auth with the wrong acid fails with
        'Unknow ac-type:'. Falls back to --ac-id if parsing fails.
        """
        url = "%s/srun_portal_pc?ac_id=%s&theme=proyx" % (self.host, self.ac_id)
        try:
            req = urllib.request.Request(url, headers={"User-Agent": "srun-portal-cli/1.0"})
            with urllib.request.urlopen(req, timeout=self.timeout, context=self._ssl_ctx) as r:
                html = r.read().decode("utf-8", "replace")
            m = re.search(r'acid\s*:\s*"(\d+)"', html)
            if m:
                return int(m.group(1))
        except Exception:
            pass
        return self.ac_id

    def get_token(self):
        res = self._jsonp("/cgi-bin/get_challenge",
                          {"username": self.username, "ip": self.ip})
        if res.get("error") != "ok":
            raise RuntimeError("get_challenge failed: %s" % json.dumps(res, ensure_ascii=False))
        return res["challenge"]

    def login(self):
        acid = self.get_real_acid()
        token = self.get_token()
        hmac_password = hmac_md5_hex(self.password, token)
        n, type_ = 200, 0                       # n=200, type=0 (desktop client)
        info_json = {
            "username": self.username,
            "password": self.password,
            "ip": self.ip,
            "acid": str(acid),
            "enc_ver": "srun_bx1",
        }
        i = encode_user_info(info_json, token)
        # chksum concatenation order is fixed by the protocol:
        s = token + self.username
        s += token + hmac_password
        s += token + str(acid)
        s += token + self.ip
        s += token + str(n)
        s += token + str(type_)
        s += token + i
        params = {
            "action": "login",
            "username": self.username,
            "password": "{MD5}" + hmac_password,
            "os": platform.system(),            # e.g. "Linux"
            "name": sys.platform,               # e.g. "linux"
            "double_stack": 0,
            "chksum": sha1_hex(s),
            "info": i,
            "ac_id": acid,
            "ip": self.ip,
            "n": n,
            "type": type_,
        }
        return self._jsonp("/cgi-bin/srun_portal", params)

    def logout(self):
        """Kick the current session offline via /cgi-bin/rad_user_dm."""
        unbind = 1
        ts = int(time.time())
        params = {
            "ip": self.ip,
            "username": self.username,
            "time": ts,
            "unbind": unbind,
            "sign": sha1_hex("%s%s%s%s%s" % (ts, self.username, self.ip, unbind, ts)),
        }
        return self._jsonp("/cgi-bin/rad_user_dm", params)


def main():
    ap = argparse.ArgumentParser(description="Srun campus portal auth (SZU)")
    ap.add_argument("--host", default="https://net.szu.edu.cn")
    ap.add_argument("--ac-id", default="1")
    ap.add_argument("--username", required=True)
    ap.add_argument("--password", required=True)
    ap.add_argument("--ip", default=None, help="override local IP sent to the portal")
    ap.add_argument("--logout", action="store_true",
                    help="kick the current session offline instead of logging in")
    args = ap.parse_args()

    portal = SrunPortal(args.host, args.ac_id, args.username, args.password, ip=args.ip)
    print("[*] portal=%s ac_id=%s user=%s ip=%s" % (args.host, args.ac_id, args.username, portal.ip))

    online, info = portal.check_online()
    if online:
        print("[+] already online: user=%s ip=%s product=%s balance=%s" % (
            info.get("user_name"), info.get("online_ip"),
            info.get("products_name"), info.get("user_balance")))
        if not args.logout:
            return 0

    if args.logout:
        if not online:
            print("[*] not online (rad_user_info error=%r), nothing to kick" % info.get("error"))
            return 0
        print("[*] kicking session offline via rad_user_dm...")
        res = portal.logout()
        print("[*] rad_user_dm response: %s" % json.dumps(res, ensure_ascii=False))
        if res.get("error") == "ok":
            print("[+] logout success")
            return 0
        print("[-] logout failed: error=%s error_msg=%s" % (res.get("error"), res.get("error_msg")))
        return 1

    print("[*] not online (rad_user_info error=%r), authenticating..." % info.get("error"))
    res = portal.login()
    print("[*] srun_portal response: %s" % json.dumps(res, ensure_ascii=False))

    if res.get("error") == "ok":
        # suc_msg may be "login_ok" or "ip_already_online_error"; both mean the
        # session is up. We deliberately do NOT auto-kick the other session.
        print("[+] auth success (suc_msg=%s)" % res.get("suc_msg"))
        return 0
    if "already_online" in str(res.get("error", "")) or "already_online" in str(res.get("error_msg", "")):
        print("[+] server says already online -> protocol accepted, treating as success")
        return 0
    print("[-] auth failed: error=%s error_msg=%s" % (res.get("error"), res.get("error_msg")))
    return 1


if __name__ == "__main__":
    sys.exit(main())

使用方法

登录(脚本先检查在线状态,在线则直接退出):

python3 srun_auth.py --username <学号/账号> --password <密码>

输出示例:

[*] portal=https://net.szu.edu.cn ac_id=1 user=2023001 ip=10.20.30.40
[*] not online (rad_user_info error='not_online_error'), authenticating...
[*] srun_portal response: {"error": "ok", "suc_msg": "login_ok", ...}
[+] auth success (suc_msg=login_ok)

注销 / 踢下线

python3 srun_auth.py --username <账号> --password <密码> --logout

可选参数

  • --host:Portal 服务器地址,默认 https://net.szu.edu.cn(其他使用深澜系统的学校改成自己的 Portal 地址即可)

  • --ac-id:Portal 页面 URL 中的入口 ac_id,默认 1;认证用的真实 acid 脚本会自动从页面 CONFIG 中解析

  • --ip:手动指定上报给 Portal 的本机 IP(一般不需要,脚本会自动探测出口 IP)

搭配 cron 实现掉线自动重连(每 5 分钟检查一次):

*/5 * * * * /usr/bin/python3 /home/user/srun_auth.py --username <账号> --password <密码> >> /tmp/srun_auth.log 2>&1

脚本在线时不做任何写操作、也不踢会话,放 cron 里长期跑是安全的。