Compare commits

...
2 Commits
Author SHA1 Message Date
RRRosmontis a436bf58b7 docs: 补充 dufe 服务(端口 9005、端点 /dufe/ginkgostu_sign_in、单元与变更记录)
- README: 架构图/端口表/目录结构/部署启用列表/验证命令/版本要点
- docs/部署与运维.md: 对外入口表、进程与端口表、目录结构、日志、变更记录
2026-09-13 03:17:19 +08:00
RRRosmontis 371d3a3624 feat(dufe): 新增银杏签到码查询服务,并在聚合层注册端点
- source/dufe/ginkgostu_sign_in/ginkgo_sign_in.py: 签到码查询服务
  (Flask + waitress 16 线程,监听 127.0.0.1:9005,端点 GET|POST /dufe/ginkgostu_sign_in)
- systemd/ginkgo_sign_in.service: 常驻服务单元,Restart=always,日志 /var/log/ginkgo_sign_in.log
- source/main.py: 新增 DUFE_BACKEND 与 handle_dufe_sign_in,
  并新增 proxy_forward()(保留请求方法与请求体,支持 POST);
  原有 proxy()(固定 GET,供 pixiv/bilibili 用)未做任何改动
2026-09-13 03:17:19 +08:00
5 changed files with 660 additions and 11 deletions
+15 -7
View File
@@ -2,7 +2,7 @@
统一接口服务。线上入口:`https://openapi.rrrosmontis.icu` 统一接口服务。线上入口:`https://openapi.rrrosmontis.icu`
聚合层把 **pixiv 图片服务**与 **bilibili 视频服务**收敛到单一对外入口。三个后端进程全部只监听 `127.0.0.1`,对外仅由 nginx 暴露 80/443——即"一个入口、零暴露端口"。 聚合层把 **pixiv 图片服务**、**bilibili 视频服务**与 **dufe 银杏签到码查询服务**收敛到单一对外入口。四个后端进程全部只监听 `127.0.0.1`,对外仅由 nginx 暴露 80/443——即"一个入口、零暴露端口"。
## 架构 ## 架构
@@ -10,6 +10,7 @@
公网 443/80 nginx (server_name openapi.rrrosmontis.icu) 公网 443/80 nginx (server_name openapi.rrrosmontis.icu)
├── /pixiv/get_image?id=xxx -> 127.0.0.1:9008 main.py 聚合层 -> 127.0.0.1:9007 pixiv ├── /pixiv/get_image?id=xxx -> 127.0.0.1:9008 main.py 聚合层 -> 127.0.0.1:9007 pixiv
├── /bilibili/get_video?bv=xxx -> 127.0.0.1:9008 main.py 聚合层 -> 127.0.0.1:9006 bilibili ├── /bilibili/get_video?bv=xxx -> 127.0.0.1:9008 main.py 聚合层 -> 127.0.0.1:9006 bilibili
├── /dufe/ginkgostu_sign_in -> 127.0.0.1:9008 main.py 聚合层 -> 127.0.0.1:9005 dufe 签到码查询(GET|POST)
├── /img-master/ /img-original/ -> nginx 直接反代 i.pximg.net(无本地后端) ├── /img-master/ /img-original/ -> nginx 直接反代 i.pximg.net(无本地后端)
└── / -> 127.0.0.1:9008(302 跳转文章页) └── / -> 127.0.0.1:9008(302 跳转文章页)
``` ```
@@ -21,6 +22,7 @@
| openapi | `127.0.0.1:9008` | `source/main.py`(aiohttp) | 聚合层,纯转发,**唯一入口** | | openapi | `127.0.0.1:9008` | `source/main.py`(aiohttp) | 聚合层,纯转发,**唯一入口** |
| pixiv | `127.0.0.1:9007` | `source/pixiv/pixiv.py`(Flask + waitress) | 图片代理 | | pixiv | `127.0.0.1:9007` | `source/pixiv/pixiv.py`(Flask + waitress) | 图片代理 |
| bilibili | `127.0.0.1:9006` | `source/bilibili/bilibili.py`(aiohttp + yt-dlp) | 视频代理 | | bilibili | `127.0.0.1:9006` | `source/bilibili/bilibili.py`(aiohttp + yt-dlp) | 视频代理 |
| dufe 签到 | `127.0.0.1:9005` | `source/dufe/ginkgostu_sign_in/ginkgo_sign_in.py`(Flask + waitress 16 线程) | 银杏(DUFE)签到码查询,`GET\|POST /dufe/ginkgostu_sign_in` |
| pixiv-token-refresh | — | `source/pixiv/refresh_token.py` | token 刷新守护(每 30–60 分钟随机) | | pixiv-token-refresh | — | `source/pixiv/refresh_token.py` | token 刷新守护(每 30–60 分钟随机) |
| bilibili-refresh | — | `source/bilibili/refresh_cookie.py` | cookie 刷新守护(每 50–70 分钟随机) | | bilibili-refresh | — | `source/bilibili/refresh_cookie.py` | cookie 刷新守护(每 50–70 分钟随机) |
@@ -31,7 +33,7 @@
``` ```
source/ source/
├── main.py # 聚合层 (127.0.0.1:9008),三条路由:/ /pixiv/get_image /bilibili/get_video ├── main.py # 聚合层 (127.0.0.1:9008),四条路由:/ /pixiv/get_image /bilibili/get_video /dufe/ginkgostu_sign_in
├── pixiv/ ├── pixiv/
│ ├── pixiv.py # Pixiv 图片服务 (127.0.0.1:9007) │ ├── pixiv.py # Pixiv 图片服务 (127.0.0.1:9007)
│ ├── refresh_token.py # Pixiv OAuth token 刷新守护 │ ├── refresh_token.py # Pixiv OAuth token 刷新守护
@@ -41,6 +43,9 @@ source/
│ ├── refresh_cookie.py # cookie 定时刷新守护 │ ├── refresh_cookie.py # cookie 定时刷新守护
│ ├── qr_login.py # 扫码登录获取 cookie │ ├── qr_login.py # 扫码登录获取 cookie
│ └── qr_login_fast.py │ └── qr_login_fast.py
├── dufe/
│ └── ginkgostu_sign_in/
│ └── ginkgo_sign_in.py # 银杏(DUFE)签到码查询 (127.0.0.1:9005, GET|POST)
└── cache/ # 运行时生成,不入库 └── cache/ # 运行时生成,不入库
├── pixiv/images # 图片缓存 ├── pixiv/images # 图片缓存
└── bilibili/ # 视频缓存 └── bilibili/ # 视频缓存
@@ -51,7 +56,8 @@ systemd/
├── pixiv.service # Pixiv 图片服务(waitress --threads=8) ├── pixiv.service # Pixiv 图片服务(waitress --threads=8)
├── bilibili.service # bilibili 视频服务 ├── bilibili.service # bilibili 视频服务
├── bilibili-refresh.service # cookie 刷新守护 ├── bilibili-refresh.service # cookie 刷新守护
└── pixiv-token-refresh.service # Pixiv token 刷新守护 ├── pixiv-token-refresh.service # Pixiv token 刷新守护
└── ginkgo_sign_in.service # 银杏(DUFE)签到码查询服务
docs/ docs/
└── 部署与运维.md # 线上服务器的路径、缓存、迁移步骤与变更记录 └── 部署与运维.md # 线上服务器的路径、缓存、迁移步骤与变更记录
``` ```
@@ -86,12 +92,12 @@ curl -s 127.0.0.1:9006/bilibili/cache_info
## 部署 ## 部署
1. **Python 环境**:三服务共用一个环境,需含 `flask`、`waitress`、`aiohttp`、`httpx`、`yt-dlp` 1. **Python 环境**:各服务共用一个环境,需含 `flask`、`waitress`、`aiohttp`、`httpx`、`yt-dlp`
2. **凭据**: 2. **凭据**:
- 复制 `source/pixiv/tokens.template.json` 为 `source/pixiv/tokens.json`(`chmod 600`)并填入实际 pixiv token - 复制 `source/pixiv/tokens.template.json` 为 `source/pixiv/tokens.json`(`chmod 600`)并填入实际 pixiv token
- 放置 `source/bilibili/cookies.txt`(`name=value` 格式),或运行 `qr_login.py` 扫码生成 - 放置 `source/bilibili/cookies.txt`(`name=value` 格式),或运行 `qr_login.py` 扫码生成
3. **服务**:将 `systemd/*.service` 安装到 `/etc/systemd/system/`,然后 3. **服务**:将 `systemd/*.service` 安装到 `/etc/systemd/system/`,然后
`systemctl daemon-reload && systemctl enable --now openapi pixiv bilibili bilibili-refresh pixiv-token-refresh` `systemctl daemon-reload && systemctl enable --now openapi pixiv bilibili ginkgo_sign_in bilibili-refresh pixiv-token-refresh`
4. **nginx**:将 `nginx/openapi.rrrosmontis.icu.conf` 放入 `/etc/nginx/conf.d/`(需存在 `$connection_upgrade` 的 map 定义),`nginx -t && systemctl reload nginx` 4. **nginx**:将 `nginx/openapi.rrrosmontis.icu.conf` 放入 `/etc/nginx/conf.d/`(需存在 `$connection_upgrade` 的 map 定义),`nginx -t && systemctl reload nginx`
5. **软链**(可选):`ln -s <部署根>/source/cache/pixiv/images /home/images` 5. **软链**(可选):`ln -s <部署根>/source/cache/pixiv/images /home/images`
6. **定时任务**:无 cron/timer——两个刷新守护均为常驻循环(内部随机间隔),systemd `Restart=always` 兜底 6. **定时任务**:无 cron/timer——两个刷新守护均为常驻循环(内部随机间隔),systemd `Restart=always` 兜底
@@ -102,18 +108,20 @@ curl -s 127.0.0.1:9006/bilibili/cache_info
curl -k "https://openapi.rrrosmontis.icu/pixiv/get_image?id=147113746" curl -k "https://openapi.rrrosmontis.icu/pixiv/get_image?id=147113746"
curl -k "https://openapi.rrrosmontis.icu/bilibili/get_video?bv=BV16a41157Q4" curl -k "https://openapi.rrrosmontis.icu/bilibili/get_video?bv=BV16a41157Q4"
curl -kI "https://openapi.rrrosmontis.icu/bilibili/get_video?bv=BV16a41157Q4" # 只看头部,省流量 curl -kI "https://openapi.rrrosmontis.icu/bilibili/get_video?bv=BV16a41157Q4" # 只看头部,省流量
curl -k "https://openapi.rrrosmontis.icu/dufe/ginkgostu_sign_in" # 无参应返回 400 JSON
curl -s 127.0.0.1:9006/bilibili/cache_info curl -s 127.0.0.1:9006/bilibili/cache_info
``` ```
端口暴露自检(三个后端都应"不可达",仅 443/80 通): 端口暴露自检(四个后端都应"不可达",仅 443/80 通):
```bash ```bash
for p in 9006 9007 9008; do timeout 3 bash -c "cat < /dev/null > /dev/tcp/<host>/$p" 2>/dev/null \ for p in 9005 9006 9007 9008; do timeout 3 bash -c "cat < /dev/null > /dev/tcp/<host>/$p" 2>/dev/null \
&& echo "$p 可连通(异常)" || echo "$p 不可达(正常)"; done && echo "$p 可连通(异常)" || echo "$p 不可达(正常)"; done
``` ```
## 版本要点 ## 版本要点
- **2026-09-13**:新增 **dufe 银杏签到码查询服务**(`source/dufe/ginkgostu_sign_in/ginkgo_sign_in.py`,Flask + waitress 16 线程,`127.0.0.1:9005`,端点 `GET|POST /dufe/ginkgostu_sign_in`),并在聚合层 `main.py` 注册该路由——为此新增 `proxy_forward()`(保留请求方法与请求体,原 `proxy()` 保持固定 GET 不变,零改动既有行为)
- **2026-09-12** - **2026-09-12**
- 端口重排 `9888/9088/19990 → 9008/9007/9006`,全部仅绑 `127.0.0.1`;pixiv 由 `0.0.0.0:9088` 收口 - 端口重排 `9888/9088/19990 → 9008/9007/9006`,全部仅绑 `127.0.0.1`;pixiv 由 `0.0.0.0:9088` 收口
- 主入口 `main.py` 从 `source/openapi/` 上移到 `source/`,systemd `WorkingDirectory` 同步为 `source/` - 主入口 `main.py` 从 `source/openapi/` 上移到 `source/`,systemd `WorkingDirectory` 同步为 `source/`
+16 -4
View File
@@ -11,6 +11,7 @@
|---|---| |---|---|
| `/pixiv/get_image?id=xxx` | `127.0.0.1:9008`(main.py 聚合层)→ `127.0.0.1:9007`(pixiv) | | `/pixiv/get_image?id=xxx` | `127.0.0.1:9008`(main.py 聚合层)→ `127.0.0.1:9007`(pixiv) |
| `/bilibili/get_video?bv=xxx` | `127.0.0.1:9008`(main.py 聚合层)→ `127.0.0.1:9006`(bilibili) | | `/bilibili/get_video?bv=xxx` | `127.0.0.1:9008`(main.py 聚合层)→ `127.0.0.1:9006`(bilibili) |
| `/dufe/ginkgostu_sign_in` | `127.0.0.1:9008`(main.py 聚合层)→ `127.0.0.1:9005`(dufe 签到码查询,支持 GET\|POST) |
| `/img-master/`、`/img-original/` | nginx 直接反代 `i.pximg.net`(无本地后端) | | `/img-master/`、`/img-original/` | nginx 直接反代 `i.pximg.net`(无本地后端) |
| `/` | `127.0.0.1:9008`(302 跳转文章页) | | `/` | `127.0.0.1:9008`(302 跳转文章页) |
@@ -20,13 +21,14 @@
## 进程与端口 ## 进程与端口
三个服务**全部只绑 `127.0.0.1`**,对外仅 nginx 的 443/80。 四个服务**全部只绑 `127.0.0.1`**,对外仅 nginx 的 443/80。
| systemd 单元 | 源码 | 监听 | 说明 | | systemd 单元 | 源码 | 监听 | 说明 |
|---|---|---|---| |---|---|---|---|
| `openapi.service` | `source/main.py` | `:9008` | 聚合层(aiohttp,纯转发,唯一入口) | | `openapi.service` | `source/main.py` | `:9008` | 聚合层(aiohttp,纯转发,唯一入口) |
| `pixiv.service` | `source/pixiv/pixiv.py` | `:9007` | Flask,由 waitress(8 线程)承载 | | `pixiv.service` | `source/pixiv/pixiv.py` | `:9007` | Flask,由 waitress(8 线程)承载 |
| `bilibili.service` | `source/bilibili/bilibili.py` | `:9006` | aiohttp + yt-dlp | | `bilibili.service` | `source/bilibili/bilibili.py` | `:9006` | aiohttp + yt-dlp |
| `ginkgo_sign_in.service` | `source/dufe/ginkgostu_sign_in/ginkgo_sign_in.py` | `:9005` | 银杏(DUFE)签到码查询,Flask + waitress(16 线程),`GET\|POST` |
| `pixiv-token-refresh.service` | `source/pixiv/refresh_token.py` | — | 常驻刷新 Pixiv OAuth token | | `pixiv-token-refresh.service` | `source/pixiv/refresh_token.py` | — | 常驻刷新 Pixiv OAuth token |
| `bilibili-refresh.service` | `source/bilibili/refresh_cookie.py` | — | 常驻刷新 bilibili cookie | | `bilibili-refresh.service` | `source/bilibili/refresh_cookie.py` | — | 常驻刷新 bilibili cookie |
@@ -45,10 +47,13 @@
│ │ ├── cookies.txt bilibili cookie(敏感,不入库) │ │ ├── cookies.txt bilibili cookie(敏感,不入库)
│ │ ├── refresh_cookie.py cookie 刷新守护 │ │ ├── refresh_cookie.py cookie 刷新守护
│ │ └── qr_login*.py 扫码登录工具(cookie 失效时人工重登用) │ │ └── qr_login*.py 扫码登录工具(cookie 失效时人工重登用)
│ ├── dufe/
│ │ └── ginkgostu_sign_in/
│ │ └── ginkgo_sign_in.py 银杏(DUFE)签到码查询服务(127.0.0.1:9005,GET|POST)
│ └── cache/ 运行时缓存(不入库) │ └── cache/ 运行时缓存(不入库)
│ ├── pixiv/images pixiv 图片缓存(代码中为 ../cache/pixiv/images) │ ├── pixiv/images pixiv 图片缓存(代码中为 ../cache/pixiv/images)
│ └── bilibili/ bilibili 视频缓存(上限 2 GiB / TTL 7 天) │ └── bilibili/ bilibili 视频缓存(上限 2 GiB / TTL 7 天)
├── systemd/ 5 个单元文件的归档副本(与 /etc/systemd/system/ 保持一致) ├── systemd/ 6 个单元文件的归档副本(与 /etc/systemd/system/ 保持一致)
├── nginx/ 宝塔导出的历史存档,非生效路径 ├── nginx/ 宝塔导出的历史存档,非生效路径
├── cert/ SSL 证书 ├── cert/ SSL 证书
├── env/ Python 环境(general + env_general.tar.gz 整包) ├── env/ Python 环境(general + env_general.tar.gz 整包)
@@ -88,7 +93,7 @@
## 迁移到新服务器 ## 迁移到新服务器
1. **环境**:解压 `env/env_general.tar.gz`,直接使用其 `bin/python`(依赖齐全,含 waitress) 1. **环境**:解压 `env/env_general.tar.gz`,直接使用其 `bin/python`(依赖齐全,含 waitress)
2. **源码**:恢复 `source/{main.py,pixiv/,bilibili/}` 到 `/home/openapi/source/` 2. **源码**:恢复 `source/{main.py,pixiv/,bilibili/,dufe/}` 到 `/home/openapi/source/`
3. **systemd**:恢复 `systemd/*.service` 到 `/etc/systemd/system/`,`daemon-reload` + `enable` + `start` 3. **systemd**:恢复 `systemd/*.service` 到 `/etc/systemd/system/`,`daemon-reload` + `enable` + `start`
4. **nginx**:套用 `conf.d/openapi.rrrosmontis.icu.conf`(并确保 `00-websocket.conf` 的 map 存在) 4. **nginx**:套用 `conf.d/openapi.rrrosmontis.icu.conf`(并确保 `00-websocket.conf` 的 map 存在)
5. **SSL**:恢复 `cert/<域名>/` 到 `/etc/nginx/cert/<域名>/` 5. **SSL**:恢复 `cert/<域名>/` 到 `/etc/nginx/cert/<域名>/`
@@ -98,19 +103,26 @@
```bash ```bash
curl -k "https://openapi.rrrosmontis.icu/pixiv/get_image?id=xxx" curl -k "https://openapi.rrrosmontis.icu/pixiv/get_image?id=xxx"
curl -k "https://openapi.rrrosmontis.icu/bilibili/get_video?bv=BV16a41157Q4" curl -k "https://openapi.rrrosmontis.icu/bilibili/get_video?bv=BV16a41157Q4"
curl -k "https://openapi.rrrosmontis.icu/dufe/ginkgostu_sign_in" # 无参应返回 400 JSON
``` ```
## 日志 ## 日志
``` ```
/var/log/openapi.log /var/log/pixiv.log /var/log/bilibili.log /var/log/openapi.log /var/log/pixiv.log /var/log/bilibili.log
/var/log/bilibili_refresh.log /var/log/pixiv_token_refresh.log /var/log/ginkgo_sign_in.log /var/log/bilibili_refresh.log /var/log/pixiv_token_refresh.log
``` ```
缓存命中/落盘会在 `bilibili.log` 打印 `cache HIT / MISS / STORE`(已加 `flush`,实时可见)。 缓存命中/落盘会在 `bilibili.log` 打印 `cache HIT / MISS / STORE`(已加 `flush`,实时可见)。
## 变更记录 ## 变更记录
**2026-09-13**
- 新增 **dufe 银杏签到码查询服务**:`source/dufe/ginkgostu_sign_in/ginkgo_sign_in.py` + `ginkgo_sign_in.service`(`127.0.0.1:9005`,Flask + waitress 16 线程,端点 `GET|POST /dufe/ginkgostu_sign_in`)
- 在聚合层 `main.py` 注册该端点:新增 `DUFE_BACKEND` 与 `proxy_forward()`(保留请求方法/请求体;原 `proxy()` 固定 GET 行为未改动)
- nginx **无需改动**:`location /` 兜底已把 `/dufe/...` 转发给 `127.0.0.1:9008`
**2026-09-12** **2026-09-12**
- 端口重排 `9888 / 9088 / 19990 → 9008 / 9007 / 9006`(全部仅 `127.0.0.1`) - 端口重排 `9888 / 9088 / 19990 → 9008 / 9007 / 9006`(全部仅 `127.0.0.1`)
@@ -0,0 +1,570 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Ginkgo (DUFE) sign-in code query service.
Endpoint: GET|POST /dufe/ginkgostu_sign_in
params: username, password(base64), url(base64)
[commit=true] optional, default true: also POST the code back
Listen: 127.0.0.1:9005 (WSGI: waitress)
"""
import base64
import binascii
import json
import logging
import re
import sys
import uuid
try:
import requests
except ImportError:
sys.exit("Missing dependency, run: pip install requests")
try:
from flask import Flask, request, jsonify
except ImportError:
sys.exit("Missing dependency, run: pip install flask")
try:
from waitress import serve
except ImportError:
sys.exit("Missing dependency, run: pip install waitress")
requests.packages.urllib3.disable_warnings()
# ------------------------------------------------------------------------------
# Site constants
# ------------------------------------------------------------------------------
GINKGO = "https://ginkgostu.dufe.edu.cn"
SSO = "https://sso.dufe.edu.cn"
CUSTOM_CODE = "kb_dconline"
UA = ("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
)
log = logging.getLogger("ginkgo")
# ------------------------------------------------------------------------------
# Error taxonomy
# AuthError -> 401 caller-side credential / captcha problem
# NoSignInCode -> 404 activity exists but has no code configured
# UpstreamError -> 502 remote service misbehaved / changed shape
# anything else -> 500 internal, generic message to the client
# ------------------------------------------------------------------------------
class AuthError(Exception):
"""Authentication failed (bad credentials, captcha rejected, locked account)."""
class NoSignInCodeError(Exception):
"""The sign-in activity exists but has no code configured."""
class UpstreamError(Exception):
"""Remote service returned an unexpected or erroneous response."""
# ==============================================================================
# Part 1: Pure-python AES-128
# ==============================================================================
_SBOX = [
0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,
0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,
0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,
0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,
0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,
0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,
0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,
0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,
0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,
0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,
0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,
0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,
0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,
0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,
0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,
0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16,
]
_INV_SBOX = [0] * 256
for _i, _v in enumerate(_SBOX):
_INV_SBOX[_v] = _i
_RCON = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1B, 0x36]
def _gmul(a, b):
p = 0
for _ in range(8):
if b & 1:
p ^= a
hi = a & 0x80
a = (a << 1) & 0xFF
if hi:
a ^= 0x1B
b >>= 1
return p
def _expand_key(key):
w = [list(key[4 * i:4 * i + 4]) for i in range(4)]
for i in range(4, 44):
t = list(w[i - 1])
if i % 4 == 0:
t = t[1:] + t[:1]
t = [_SBOX[b] for b in t]
t[0] ^= _RCON[i // 4 - 1]
w.append([w[i - 4][j] ^ t[j] for j in range(4)])
return w
def _add_round_key(s, w, rnd):
for c in range(4):
for r in range(4):
s[r][c] ^= w[rnd * 4 + c][r]
def _sub_bytes(s, box):
for r in range(4):
for c in range(4):
s[r][c] = box[s[r][c]]
def _shift_rows(s):
for r in range(1, 4):
s[r] = s[r][r:] + s[r][:r]
def _inv_shift_rows(s):
for r in range(1, 4):
s[r] = s[r][-r:] + s[r][:-r]
def _mix_columns(s):
for c in range(4):
a = [s[r][c] for r in range(4)]
s[0][c] = _gmul(a[0], 2) ^ _gmul(a[1], 3) ^ a[2] ^ a[3]
s[1][c] = a[0] ^ _gmul(a[1], 2) ^ _gmul(a[2], 3) ^ a[3]
s[2][c] = a[0] ^ a[1] ^ _gmul(a[2], 2) ^ _gmul(a[3], 3)
s[3][c] = _gmul(a[0], 3) ^ a[1] ^ a[2] ^ _gmul(a[3], 2)
def _inv_mix_columns(s):
for c in range(4):
a = [s[r][c] for r in range(4)]
s[0][c] = _gmul(a[0],14) ^ _gmul(a[1],11) ^ _gmul(a[2],13) ^ _gmul(a[3],9)
s[1][c] = _gmul(a[0],9) ^ _gmul(a[1],14) ^ _gmul(a[2],11) ^ _gmul(a[3],13)
s[2][c] = _gmul(a[0],13) ^ _gmul(a[1],9) ^ _gmul(a[2],14) ^ _gmul(a[3],11)
s[3][c] = _gmul(a[0],11) ^ _gmul(a[1],13) ^ _gmul(a[2],9) ^ _gmul(a[3],14)
class AES128:
def __init__(self, key):
if len(key) != 16:
raise ValueError("AES-128 requires a 16-byte key, got %d" % len(key))
self.w = _expand_key(key)
def encrypt_block(self, block):
s = [[block[r + 4 * c] for c in range(4)] for r in range(4)]
_add_round_key(s, self.w, 0)
for rnd in range(1, 10):
_sub_bytes(s, _SBOX)
_shift_rows(s)
_mix_columns(s)
_add_round_key(s, self.w, rnd)
_sub_bytes(s, _SBOX)
_shift_rows(s)
_add_round_key(s, self.w, 10)
return bytes(s[r][c] for c in range(4) for r in range(4))
def decrypt_block(self, block):
s = [[block[r + 4 * c] for c in range(4)] for r in range(4)]
_add_round_key(s, self.w, 10)
for rnd in range(9, 0, -1):
_inv_shift_rows(s)
_sub_bytes(s, _INV_SBOX)
_add_round_key(s, self.w, rnd)
_inv_mix_columns(s)
_inv_shift_rows(s)
_sub_bytes(s, _INV_SBOX)
_add_round_key(s, self.w, 0)
return bytes(s[r][c] for c in range(4) for r in range(4))
def _pkcs7_unpad(data):
if not data:
return data
n = data[-1]
if 1 <= n <= 16 and data[-n:] == bytes([n]) * n:
return data[:-n]
return data
def _zero_pad(data):
if len(data) % 16:
data += b"\x00" * (16 - len(data) % 16)
return data
def aes_ecb_decrypt_b64(cipher_b64, key_text):
aes = AES128(key_text[:16].encode("utf-8"))
raw = base64.b64decode(cipher_b64)
out = b"".join(aes.decrypt_block(raw[i:i + 16]) for i in range(0, len(raw), 16))
return _pkcs7_unpad(out)
def aes_cbc_encrypt_b64_zero(plain, key_text):
key = key_text[:16].encode("utf-8")
aes = AES128(key)
iv = key
data = _zero_pad(plain.encode("utf-8"))
out, prev = b"", iv
for i in range(0, len(data), 16):
blk = bytes(x ^ y for x, y in zip(data[i:i + 16], prev))
prev = aes.encrypt_block(blk)
out += prev
return base64.b64encode(out).decode("ascii")
# ==============================================================================
# Part 2: Session & URL parsing
# ==============================================================================
def new_session():
s = requests.Session()
s.trust_env = False
s.verify = False
s.headers.update({"User-Agent": UA, "Accept-Language": "zh-CN,zh;q=0.9"})
return s
def parse_course_url(url):
m = re.search(
r"/course/detail/(?P<courseId>\d+)/(?P<batchNo>[^/]+)/"
r"(?P<weekTimeId>\d+)/signin/(?P<signInId>\d+)/(?P<type>\d+)", url)
if not m:
raise ValueError("Cannot parse course URL: %s" % url)
return m.groupdict()
# ==============================================================================
# Part 3: Login flow
# ==============================================================================
def get_unify_config(sess):
r = sess.get(GINKGO + "/student/getLoginUnifySet", timeout=20)
r.raise_for_status()
try:
return r.json()["data"]
except (ValueError, KeyError) as e:
raise UpstreamError("Malformed getLoginUnifySet response: %s" % e)
def open_authorize(sess, cfg):
params = {
"response_type": cfg["responseType"],
"client_id": cfg["clientId"],
"redirect_uri": cfg["rollBackUrl"],
"scope": cfg.get("scope", "") or "",
"state": cfg.get("state", "") or "",
}
r = sess.get(cfg["loginUrl"], params=params, timeout=20, allow_redirects=True)
r.raise_for_status()
m = re.search(r'name="__token__"\s+value="([^"]+)"', r.text)
if not m:
raise UpstreamError("CSRF token __token__ not found on login page")
return m.group(1), r.url
def exploit_slider(sess, referer):
url = (SSO + "/auth/widget?layer=image_verify&widget=Slider"
"&action=get_verify&type=login_image_verify")
r = sess.post(url, data={"width": 280, "height": 155, "block_size": 80},
headers={"X-Requested-With": "XMLHttpRequest", "Referer": referer},
timeout=20)
r.raise_for_status()
try:
d = r.json()["data"]
token, cipher = d["token"], d["d"]
except (ValueError, KeyError, TypeError) as e:
raise UpstreamError("Malformed slider challenge response: %s" % e)
x = aes_ecb_decrypt_b64(cipher, token).decode("utf-8", "replace")
return {"token": token, "x": x, "cipher": cipher}
def submit_login(sess, post_url, csrf, username, password, slider, referer):
form = {
"fingerprint": uuid.uuid4().hex,
"username": username,
"password": aes_cbc_encrypt_b64_zero(password, csrf),
"verify_token": slider["token"],
"verify_code": slider["x"],
"__token__": csrf,
}
return sess.post(post_url, data=form, timeout=25, allow_redirects=False,
headers={"Referer": referer, "Origin": SSO,
"Content-Type": "application/x-www-form-urlencoded"})
def exchange_token(sess, cfg, code):
r = sess.post(GINKGO + "/student/authLogin", data={"code": code},
timeout=20,
headers={"Content-Type": "application/x-www-form-urlencoded"})
try:
j = r.json()
except ValueError as e:
raise UpstreamError("authLogin returned non-JSON: %s" % e)
if j.get("code") != "2000":
raise AuthError("OAuth code exchange refused: %s"
% json.dumps(j, ensure_ascii=False))
return j["data"]["token"]
# ==============================================================================
# Part 4: Sign-in API
# ==============================================================================
def api_headers(token, fingerprint=None):
return {
"User-Agent": UA,
"Origin": GINKGO,
"Referer": GINKGO + "/",
"Content-Type": "application/x-www-form-urlencoded",
"token": token,
"fingerprint": fingerprint or uuid.uuid4().hex,
}
def call_api(sess, path, data, token):
return sess.post(GINKGO + path, data=data, timeout=20,
headers=api_headers(token))
def commit_sign_in(sess, token, sign_in_id, batch_no, referer, code):
"""
POST /student/signIn/InTheSign to actually check in with the given code.
Mirrors the browser request:
headers: batchno, fingerprint, token, Referer (course page)
body: signInId=<id>&code=<code>
Returns (ok: bool, message: str, raw: dict|None).
"""
headers = api_headers(token)
if batch_no:
# NOTE: the browser sends a "batchno" header that does not always equal
# the batchNo segment of the course URL. We pass the URL segment here;
# the server appears not to enforce it.
headers["batchno"] = str(batch_no)
headers["Referer"] = referer
try:
r = sess.post(
GINKGO + "/student/signIn/InTheSign",
data={"signInId": str(sign_in_id), "code": str(code)},
timeout=20,
headers=headers,
)
except requests.RequestException as e:
return False, "request to InTheSign failed: %s" % e, None
if r.status_code != 200:
return False, "InTheSign returned HTTP %d" % r.status_code, None
try:
j = r.json()
except ValueError:
return False, "InTheSign returned non-JSON: %s" % r.text[:200], None
api_code = str(j.get("code", ""))
server_msg = (j.get("message") or j.get("msg") or "").strip()
if api_code == "2000":
return True, server_msg or "accepted", j
if server_msg:
reason = server_msg
elif api_code:
reason = "upstream returned code %s" % api_code
else:
reason = ("unrecognized upstream response: %s"
% json.dumps(j, ensure_ascii=False)[:200])
return False, reason, j
def fetch_sign_in_info(username, password, course_url, do_commit=True):
route = parse_course_url(course_url)
sign_in_id = route["signInId"]
batch_no = route.get("batchNo")
sess = new_session()
cfg = get_unify_config(sess)
csrf, page_url = open_authorize(sess, cfg)
slider = exploit_slider(sess, page_url)
resp = submit_login(sess, page_url, csrf, username, password, slider, page_url)
loc = resp.headers.get("Location", "")
m = re.search(r"[?&]code=([A-Za-z0-9_\-]+)", loc)
if not m:
raise AuthError(
"Login failed: no OAuth code in redirect (HTTP %s)" % resp.status_code)
auth_code = m.group(1)
token = exchange_token(sess, cfg, auth_code)
r = call_api(sess, "/student/signIn/getSignInInfo",
{"signInId": sign_in_id}, token)
try:
j = r.json()
except ValueError as e:
raise UpstreamError("getSignInInfo returned non-JSON: %s" % e)
data = j.get("data") or {}
model = data.get("signInModel") or {}
code = model.get("code")
if code is None or code == "":
raise NoSignInCodeError(
"Sign-in activity %s has no code configured" % sign_in_id)
result = {
"signInId": sign_in_id,
"signInCode": code,
"type": model.get("type"),
"title": model.get("title"),
"status": data.get("signStatus"),
}
if do_commit:
ok, reason, _raw = commit_sign_in(
sess, token, sign_in_id, batch_no, course_url, code
)
result["commit"] = {"success": ok, "message": reason}
return result
# ==============================================================================
# Part 5: HTTP service (WSGI app)
# ==============================================================================
app = Flask(__name__)
def _b64_decode(value, field):
"""
Decode a base64 query/form parameter tolerantly.
Handles:
* '+' turned into ' ' by application/x-www-form-urlencoded
* trailing newline from `base64` CLI without -w0
* both standard (+/) and urlsafe (-_) alphabets
"""
v = (value or "").strip()
v = v.replace(" ", "+").replace("\r", "").replace("\n", "")
if not v:
raise ValueError("Empty base64 for '%s'" % field)
try:
return base64.b64decode(v, altchars=b"-_", validate=True).decode("utf-8")
except (binascii.Error, UnicodeDecodeError, ValueError) as e:
raise ValueError("Invalid base64 for '%s': %s" % (field, e))
def _error(http_status, code, msg):
return jsonify({"code": code, "msg": msg, "data": None}), http_status
@app.route("/dufe/ginkgostu_sign_in", methods=["GET", "POST"])
def ginkgostu_sign_in():
args = request.values
username = (args.get("username") or "").strip()
password_b64 = args.get("password") or ""
url_b64 = args.get("url") or ""
commit_arg = (args.get("commit") or "true").strip().lower()
do_commit = commit_arg not in ("0", "false", "no")
if not (username and password_b64 and url_b64):
return _error(400, 400,
"Missing parameters: username, password(base64), url(base64)")
try:
password = _b64_decode(password_b64, "password")
course_url = _b64_decode(url_b64, "url")
except ValueError as e:
return _error(400, 400, str(e))
log.info("query start user=%s commit=%s", username[:2] + "***", do_commit)
try:
data = fetch_sign_in_info(username, password, course_url, do_commit=do_commit)
commit_info = data.get("commit") or {}
if do_commit and not commit_info.get("success", True):
log.warning("sign-in commit failed user=%s reason=%s",
username[:2] + "***", commit_info.get("message"))
return jsonify({
"code": 200,
"msg": "sign-in failed: %s" % commit_info.get("message", "unknown reason"),
"data": data,
})
log.info("query ok user=%s code=%s", username[:2] + "***", data.get("signInCode"))
return jsonify({"code": 200, "msg": "ok", "data": data})
except AuthError as e:
log.warning("auth failed: %s", e)
return _error(401, 401, str(e))
except NoSignInCodeError as e:
log.info("no code: %s", e)
return _error(404, 404, str(e))
except UpstreamError as e:
log.error("upstream error: %s", e)
return _error(502, 502, str(e))
except ValueError as e:
log.warning("bad request: %s", e)
return _error(400, 400, str(e))
except requests.RequestException as e:
log.error("upstream request failed: %s", e)
return _error(502, 502, "Upstream request failed")
except Exception:
log.exception("internal error")
return _error(500, 500, "Internal error")
@app.route("/healthz", methods=["GET"])
def healthz():
return jsonify({"code": 200, "msg": "ok", "data": None})
# ==============================================================================
# Part 6: WSGI entrypoint
# ==============================================================================
HOST = "127.0.0.1"
PORT = 9005
THREADS = 16
BACKLOG = 128
CONNECTION_LIMIT = 256
CHANNEL_TIMEOUT = 120
def main():
log.info("serving on http://%s:%d (threads=%d)", HOST, PORT, THREADS)
serve(
app,
host=HOST,
port=PORT,
threads=THREADS,
backlog=BACKLOG,
connection_limit=CONNECTION_LIMIT,
channel_timeout=CHANNEL_TIMEOUT,
ident="ginkgo-signin",
)
return 0
if __name__ == "__main__":
sys.exit(main())
+42
View File
@@ -3,6 +3,7 @@ from aiohttp import web, ClientSession, ClientTimeout
PIXIV_BACKEND = "http://127.0.0.1:9007" PIXIV_BACKEND = "http://127.0.0.1:9007"
BILIBILI_BACKEND = "http://127.0.0.1:9006" BILIBILI_BACKEND = "http://127.0.0.1:9006"
DUFE_BACKEND = "http://127.0.0.1:9005"
async def handle_pixiv(request): async def handle_pixiv(request):
@@ -21,6 +22,14 @@ async def handle_bilibili(request):
return await proxy(request, target) return await proxy(request, target)
async def handle_dufe_sign_in(request):
# ginkgo_sign_in.service(127.0.0.1:9005),端点 /dufe/ginkgostu_sign_in,支持 GET|POST
target = f"{DUFE_BACKEND}/dufe/ginkgostu_sign_in"
if request.query_string:
target += "?" + request.query_string
return await proxy_forward(request, target)
async def proxy(request, target): async def proxy(request, target):
timeout = ClientTimeout(total=300) timeout = ClientTimeout(total=300)
async with ClientSession(timeout=timeout) as session: async with ClientSession(timeout=timeout) as session:
@@ -42,6 +51,37 @@ async def proxy(request, target):
) )
async def proxy_forward(request, target):
# 保留请求方法与请求体的转发,供需要 POST 的后端使用(dufe 签到码查询)
timeout = ClientTimeout(total=300)
data = None
fwd_headers = {}
if request.method not in ("GET", "HEAD"):
data = await request.read()
ctype = request.headers.get("Content-Type")
if ctype:
fwd_headers["Content-Type"] = ctype
async with ClientSession(timeout=timeout) as session:
try:
async with session.request(
request.method, target, data=data, headers=fwd_headers
) as resp:
body = await resp.read()
headers = {}
for key in ("Content-Type", "Content-Disposition", "Cache-Control"):
if key in resp.headers:
headers[key] = resp.headers[key]
return web.Response(
body=body,
status=resp.status,
headers=headers,
)
except Exception as e:
return web.json_response(
{"error": "backend unreachable", "detail": str(e)}, status=502
)
async def handle_root(request): async def handle_root(request):
raise web.HTTPFound("https://rrrosmontis.icu/articles/index.html?id=00003") raise web.HTTPFound("https://rrrosmontis.icu/articles/index.html?id=00003")
@@ -50,6 +90,8 @@ app = web.Application()
app.router.add_get("/", handle_root) app.router.add_get("/", handle_root)
app.router.add_get("/pixiv/get_image", handle_pixiv) app.router.add_get("/pixiv/get_image", handle_pixiv)
app.router.add_get("/bilibili/get_video", handle_bilibili) app.router.add_get("/bilibili/get_video", handle_bilibili)
app.router.add_get("/dufe/ginkgostu_sign_in", handle_dufe_sign_in)
app.router.add_post("/dufe/ginkgostu_sign_in", handle_dufe_sign_in)
if __name__ == "__main__": if __name__ == "__main__":
web.run_app(app, host="127.0.0.1", port=9008) web.run_app(app, host="127.0.0.1", port=9008)
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=Ginkgo Stu Sign-in Service (dufe)
After=network-online.target
Wants=network-online.target
[Service]
Environment=PATH=/home/openapi/env/general/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Type=simple
WorkingDirectory=/home/openapi/source/dufe/ginkgostu_sign_in
ExecStart=/home/openapi/env/general/bin/python /home/openapi/source/dufe/ginkgostu_sign_in/ginkgo_sign_in.py
Restart=always
RestartSec=5
StandardOutput=append:/var/log/ginkgo_sign_in.log
StandardError=append:/var/log/ginkgo_sign_in.log
[Install]
WantedBy=multi-user.target