You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
hexo-theme-yorha-test/source/_posts/srun-login-analyze.md

742 lines
23 KiB

title: wust武科大校园网深澜srun认证api分析(后续)
author: lensfrex
cover: https://oss-img.ciduid.top/blog/covers/90778009_p0_compress.png
tags:
- 网络
categories:
- 网络
date: 2022-11-13 20:22:00
---
---
> 上回书说到,彼时的璃月....
书接上回:[某校园网认证api分析](/2022/0706/school-network-auth/)
上一次对校园网web认证做了个简单的请求分析,但是一些关键的字段是用了某种神奇的算法进行了加密发送,这里就对这个算法进行简单的分析,并且用go语言重新实现一遍
(说起来好像从7月初就一直鸽到了现在了吧...嘘🤫)
---
## 抓包分析
干这种事情首先要做的就是抓包,最简单的就是直接浏览器F12,完整地过一次登录请求
### 1. 请求challenge码
最先发出来的是一个GET请求:
`http://59.68.177.183/cgi-bin/get_challenge?callback=jQuery1124005588867363182781_1668219915986&username=202100000000&ip=10.16.1.9&_=1668219915990`
请求参数:
```
callback: jQuery1124005588867363182781_1668219915986
username: 202100000000
ip: 10.16.1.9
_: 1668219915990
```
这里的callback一定要设置,否则返回的是杂乱的数据(没有json结构),只有一个ok,是没有咱们想要的challenge码的
其他字段顾名思义,ip就是当前连接之后获取到的ip,下划线字段是当前时间戳(毫秒),注意这个时间是GMT时间,而不是咱们的东八区时间
获取到的响应如下:
```json
jQuery1124005588867363182781_1668219915986({
"challenge": "3c6d08d667d0ee0ccad77c55b19d3e4ab2552f7163ec40a9389095a18f86c398",
"client_ip": "10.16.1.9",
"ecode": 0,
"error": "ok",
"error_msg": "",
"expire": "52",
"online_ip": "10.16.1.9",
"res": "ok",
"srun_ver": "SRunCGIAuthIntfSvr V1.18 B20211105",
"st": 1668219964
})
```
这里我们只需要关心challenge字段的值就好了,后面有用。
### 2. 请求认证
继续分析,接下来的也是一个GET请求:
`http://59.68.177.183/cgi-bin/srun_portal?callback=jQuery1124005588867363182781_1668219915986&action=login&username=202100000000&password=%7BMD5%7Dr096b1282e1a50ce8f9d15aa3a29acf8&os=Linux&name=Linux&double_stack=0&chksum=dfa1459124878b981873bd6d853c88b1eb716e6d&info=%7BSRBX1%7D76z3vHCupat5bbo3et2MbNplTCn0FXWKd%2FhazIzb26HpqJsIoolCvtcLPk5mnstlz0J%2BebaMjGfckCzVHlaUqqGaAJ7XM%2FEQ83p0D2TPbjDG7f%2FiFEiadcQkHJpiwOBr800LDP7yrA4%3D&ac_id=7&ip=10.16.1.9&n=200&type=1&_=1668219915991`
请求参数(部分字段经过修改,请以实际为准):
```
callback: jQuery1124005588867363182781_1668219915986
action: login
username: 202100000000
password: {MD5}r096b1282e1a50ce8f9d15aa3a29acf8
os: Linux
name: Linux
double_stack: 0
chksum: dfa1459124878b981873bd6d853c88b1eb716e6d
info: {SRBX1}76z3vHCupat5bbo3et2MbNplTCn0FXWKd/hazIzs26HpqJsIoolCvtcLPd5mnstlz0J+ebaMjGfckCzVHlaUqqGaAJ7XM/EQ83p0D2TPbsDG7f/iFEiadcQkHJpiwOBr8002DP7yrA4=
ac_id: 7
ip: 10.16.1.9
n: 200
type: 1
_: 1668219915991
```
翻看js源码,不难发现大部分字段其实是写死了的(在下一节会详细讲),动态的字段也比较好算出来,除了info字段算法比较特殊外,其他的都是普通的算法,翻翻js源码就知道了
这个请求完成以后,不出意外的话,就能上网了
这个请求的响应如下:
```json
jQuery1124005588867363182781_1668219915986({
"ServerFlag": 0,
"ServicesIntfServerIP": "172.26.23.173",
"ServicesIntfServerPort": "8001",
"access_token": "3c6d08d667d0ee0ccad77c55b19d3e4ab2552f7163ec40a9389095a18f86c398",
"checkout_date": 0,
"client_ip": "10.16.1.9",
"ecode": 0,
"error": "ok",
"error_msg": "",
"online_ip": "10.16.1.9",
"ploy_msg": "E0000: Login is successful.",
"real_name": "",
"remain_flux": 0,
"remain_times": 0,
"res": "ok",
"srun_ver": "SRunCGIAuthIntfSvr V1.18 B20211105",
"suc_msg": "ip_already_online_error",
"sysver": "1.01.20211105",
"username": "202100000000",
"wallet_balance": 0
})
```
这下认证的请求部分就结束了
## 刨
看完了请求是怎么请求的,现在就来看看这些请求的数据是怎么个算出来的吧
---
这个认证页面主要的业务逻辑主要是放在了[`Portal.js`](http://59.68.177.183/static/themes/pro/js/Portal.js?v=2.00.20211105)这个js脚本里
这个js脚本简直是好得不得了,只有简单的混淆(或者说...其实根本就没混淆),而且还有详细的注释,真的是太良心了
简单找找,能够发现负责请求的函数就是这段(大约在995行附近):
<details>
<summary>小心,有巨量代码</summary>
```javascript
value: function value(obj) {
// 加密常量
var type = 1;
var n = 200;
var enc = 'srun_bx1'; // 用户信息
var username = _this.userInfo.username + _this.userInfo.domain;
var password = _this.userInfo.password;
var ac_id = _this.portalInfo.acid; // 正在等待中的请求
var pendingReqNum = 0; // 请求成功的消息
var successMsg = ''; // 发起认证方法
var sendAuth = function sendAuth() {
var host = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '';
// 双栈认证时 IP 参数为空
var ip = _this.portalInfo.doub && host ? '' : _this.userInfo.ip; // 获取 Token
_classPrivateFieldGet(_assertThisInitialized(_this), _getToken).call(_assertThisInitialized(_this), host, ip, function (token) {
// 用户密码 MD5 加密
var hmd5 = md5(password, token); // 用户信息加密
var i = _classPrivateFieldGet(_assertThisInitialized(_this), _encodeUserInfo).call(_assertThisInitialized(_this), {
username: username,
password: password,
ip: ip,
acid: ac_id,
enc_ver: enc
}, token);
var str = token + username;
str += token + hmd5;
str += token + ac_id;
str += token + ip;
str += token + n;
str += token + type;
str += token + i; // 防止 IPv6 请求网络不通进行 try catch
try {
pendingReqNum += 1; // 发起认证请求
_this.ajax.jsonp({
host: host,
url: _classPrivateFieldGet(_assertThisInitialized(_this), _api).auth,
params: {
action: 'login',
username: username,
password: _this.userInfo.otp ? '{OTP}' + password : '{MD5}' + hmd5,
os: _this.portalInfo.userDevice.device,
name: _this.portalInfo.userDevice.platform,
// 未开启双栈认证,参数为 0
// 开启双栈认证,向 Portal 当前页面 IP 认证时,参数为 1
// 开启双栈认证,向 Portal 另外一种 IP 认证时,参数为 0
double_stack: _this.portalInfo.doub && !host ? 1 : 0,
chksum: sha1(str),
info: i,
ac_id: ac_id,
ip: ip,
n: n,
type: type
},
success: function success(res) {
pendingReqNum -= 1; // 认证成功,用户上线
_this.online = true; // 更改登录状态为结束
_this.running.login = false; // IP 已经在线了 - 给出提示
if (res.suc_msg === 'ip_already_online_error' && obj.error) return _this.confirm({
message: _this.translate('ip_already_online_error'),
confirm: function confirm() {
if (obj.error) obj.error();
}
}); // 翻译后的认证成功信息
successMsg = _this.translate(res);
},
error: function error(res) {
pendingReqNum -= 1; // 更改登录状态为结束
_this.running.login = false; // 若 ecode 为 E2620 且开启了在线设备管理功能
// E2620: 超出允许的在线数目
if (res.ecode === 'E2620' && CREATER.useOnlineDeviceMgr) return _this.confirm({
message: _this.translate('E2620Tips'),
confirm: function confirm() {
_this.dialog.open('onlineDeviceMgr', function () {
_this.getOnlineDevice();
});
}
}); // IP 已经在线了 - 重新认证
if (res.error_msg === 'ip_already_online_error') return _this.reAuth(obj); // 需要查询日志的情况
if (res.error_msg === 'not_online_error') return _this.showLog();
if (res.error_msg === 'no_response_data_error') return _this.showLog();
if (res.error_msg === 'RD000') return _this.showLog(); // 若提示修改密码
if (res.error_msg === 'user_must_modify_password') return _this.confirm({
message: _this.translate(res),
confirmText: _this.translate('ToChangePassword'),
confirm: function confirm() {
return $('#forget').click();
},
cancel: function cancel() {}
}); // 错误提示
_this.confirm({
message: _this.translate(res),
confirm: function confirm() {
if (obj.error) obj.error(res);
}
});
}
});
} catch (err) {
// 因为 IPv6 网络问题导致的认证失败
pendingReqNum -= 1;
}
});
}; // 使用 Portal 页面 IP 类型认证
sendAuth(); // 若符合双栈认证,则进行双栈认证
if (_this.portalInfo.doub) {
var _this$portalInfo3 = _this.portalInfo,
ipv4 = _this$portalInfo3.ipv4,
ipv6 = _this$portalInfo3.ipv6; // 发起另一类型认证
sendAuth(_this.portalInfo.nowType === 'ipv4' ? "[".concat(ipv6, "]") : ipv4);
} // 等待全部请求完成,没有 pending 中的请求则代表全部请求完成,<= 0 防止 catch 与 ajax error 方法重复
var timer = setInterval(function () {
// 全部请求完成,认证成功
if (pendingReqNum <= 0 && _this.online) {
clearInterval(timer); // 认证成功
if (obj.success) obj.success(successMsg); // 若未传入注销成功回调函数,则默认重定向至 index
if (!obj.success) _this.toSuccess();
} // 全部请求完成,认证失败
if (pendingReqNum <= 0 && !_this.online) {
clearInterval(timer);
}
}, 500); // 3s 后清空 Pending Num
setTimeout(function () {
return pendingReqNum = 0;
}, 3000);
}
```
</details>
核心的是这段:
<details>
<summary>展开查看</summary>
```javascript
var sendAuth = function sendAuth() {
var host = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '';
// 双栈认证时 IP 参数为空
var ip = _this.portalInfo.doub && host ? '' : _this.userInfo.ip; // 获取 Token
_classPrivateFieldGet(_assertThisInitialized(_this), _getToken).call(_assertThisInitialized(_this), host, ip, function (token) {
// 用户密码 MD5 加密
var hmd5 = md5(password, token); // 用户信息加密
var i = _classPrivateFieldGet(_assertThisInitialized(_this), _encodeUserInfo).call(_assertThisInitialized(_this), {
username: username,
password: password,
ip: ip,
acid: ac_id,
enc_ver: enc
}, token);
var str = token + username;
str += token + hmd5;
str += token + ac_id;
str += token + ip;
str += token + n;
str += token + type;
str += token + i; // 防止 IPv6 请求网络不通进行 try catch
try {
pendingReqNum += 1; // 发起认证请求
_this.ajax.jsonp({
host: host,
url: _classPrivateFieldGet(_assertThisInitialized(_this), _api).auth,
params: {
action: 'login',
username: username,
password: _this.userInfo.otp ? '{OTP}' + password : '{MD5}' + hmd5,
os: _this.portalInfo.userDevice.device,
name: _this.portalInfo.userDevice.platform,
// 未开启双栈认证,参数为 0
// 开启双栈认证,向 Portal 当前页面 IP 认证时,参数为 1
// 开启双栈认证,向 Portal 另外一种 IP 认证时,参数为 0
double_stack: _this.portalInfo.doub && !host ? 1 : 0,
chksum: sha1(str),
info: i,
ac_id: ac_id,
ip: ip,
n: n,
type: type
},
```
</details>
第一个请求的格式很好理解,这里就不多分析了,咱先来看看第二个请求的代码
通过第二个请求的参数分析,不难发现这些字段来自这里:
```javascript
action: 'login',
username: username,
password: _this.userInfo.otp ? '{OTP}' + password : '{MD5}' + hmd5,
os: _this.portalInfo.userDevice.device,
name: _this.portalInfo.userDevice.platform,
// 未开启双栈认证,参数为 0
// 开启双栈认证,向 Portal 当前页面 IP 认证时,参数为 1
// 开启双栈认证,向 Portal 另外一种 IP 认证时,参数为 0
double_stack: _this.portalInfo.doub && !host ? 1 : 0,
chksum: sha1(str),
info: i,
ac_id: ac_id,
ip: ip,
n: n,
type: type
```
上下文找找,这里的`action`, `os`, `name`, `n`等等都是固定的,推测的含义大概如下:
| 字段 | 含义 |
| --- | --- |
| action | 操作,这里当然就是login |
| username | 用户名,就是学号 |
| password | 密码,在这里就很容易发现其实就是md5(密码原文+challenge) |
| os | 操作系统 |
| name | 操作系统名称,在这个js文件里边都能找到定义 |
| double_stack | 看注释就知道是否为双栈(ipv4/6)认证(但是不知道为什么没有ipv6...) |
| chksum | 参数校验,值为str字段进行sha1计算之后的值了 |
| info | 一些登录信息,具体算法比较特殊,下面再仔细讲讲 |
| ac_id | ac id |
| n | 不是很清楚,但是看上面的代码是写死固定的200 |
| type | 类型,不知道是什么的类型,看上面写的也是写死的1 |
### info字段加密分析
不难看出,对i赋值的是这段代码
```javascript
var i = _classPrivateFieldGet(_assertThisInitialized(_this), _encodeUserInfo).call(_assertThisInitialized(_this), {
username: username,
password: password,
ip: ip,
acid: ac_id,
enc_ver: enc
}, token);
```
这些函数都只是虚晃一枪,都只是一些回调之类的操作,真正执行函数的是`_encodeUserInfo`函数,call里边异步回调的时候把一个js对象和token(也就是上面拿到的challenge码)传到了`_encodeUserInfo`函数里边。
那这个函数究竟在哪?
不用太复杂的方法,直接粗暴Ctrl+F找就行了,找一找,原来是在这里:(约681行处)
<details>
<summary>展开查看</summary>
```javascript
_encodeUserInfo.set(_assertThisInitialized(_this), {
writable: true,
value: function value(info, token) {
// 克隆自 $.base64,防止污染
var base64 = _this.clone($.base64); // base64 设置 Alpha
base64.setAlpha('LVoJPiCN2R8G90yg+hmFHuacZ1OWMnrsSTXkYpUq/3dlbfKwv6xztjI7DeBE45QA'); // 用户信息转 JSON
info = JSON.stringify(info);
function encode(str, key) {
if (str === '') return '';
var v = s(str, true);
var k = s(key, false);
if (k.length < 4) k.length = 4;
var n = v.length - 1,
z = v[n],
y = v[0],
c = 0x86014019 | 0x183639A0,
m,
e,
p,
q = Math.floor(6 + 52 / (n + 1)),
d = 0;
while (0 < q--) {
d = d + c & (0x8CE0D9BF | 0x731F2640);
e = d >>> 2 & 3;
for (p = 0; p < n; p++) {
y = v[p + 1];
m = z >>> 5 ^ y << 2;
m += y >>> 3 ^ z << 4 ^ (d ^ y);
m += k[p & 3 ^ e] ^ z;
z = v[p] = v[p] + m & (0xEFB8D130 | 0x10472ECF);
}
y = v[0];
m = z >>> 5 ^ y << 2;
m += y >>> 3 ^ z << 4 ^ (d ^ y);
m += k[p & 3 ^ e] ^ z;
z = v[n] = v[n] + m & (0xBB390742 | 0x44C6F8BD);
}
return l(v, false);
}
function s(a, b) {
var c = a.length;
var v = [];
for (var i = 0; i < c; i += 4) {
v[i >> 2] = a.charCodeAt(i) | a.charCodeAt(i + 1) << 8 | a.charCodeAt(i + 2) << 16 | a.charCodeAt(i + 3) << 24;
}
if (b) v[v.length] = c;
return v;
}
function l(a, b) {
var d = a.length;
var c = d - 1 << 2;
if (b) {
var m = a[d - 1];
if (m < c - 3 || m > c) return null;
c = m;
}
for (var i = 0; i < d; i++) {
a[i] = String.fromCharCode(a[i] & 0xff, a[i] >>> 8 & 0xff, a[i] >>> 16 & 0xff, a[i] >>> 24 & 0xff);
}
return b ? a.join('').substring(0, c) : a.join('');
}
return '{SRBX1}' + base64.encode(encode(info, token));
}
});
```
</details>
好!接下来就是改写了。
一般来说,如果过于复杂的话咱们通常是直接调用js执行,但是嘛...这就可能会把咱们的程序搞得非常庞大,效率也很低,但是也不是不能用是吧...
这段看起来不是很难,那咱就开工
## 改造,开工!
粗略看下来,主要就是用encode函数生成了一个字符串。再对这个字符串进行一次base64编码(当然,是被动过手脚的)
> 就是这里被动了手脚:`base64.setAlpha('LVoJPiCN2R8G90yg+hmFHuacZ1OWMnrsSTXkYpUq/3dlbfKwv6xztjI7DeBE45QA');`
>
> 虽然说是动了手脚,但是具体的base64算法是一样的,只不过是字母表被换了顺序而已
encode函数在开头调用了两次s函数,把str和key转成了又一个神奇的数组,这里的str就是前面传进来的,转成了json字符串的js对象
那咱们就先改造s函数。
<details>
<summary>s函数</summary>
```javascript
function s(a, b) {
var c = a.length;
var v = [];
for (var i = 0; i < c; i += 4) {
v[i >> 2] = a.charCodeAt(i) | a.charCodeAt(i + 1) << 8 | a.charCodeAt(i + 2) << 16 | a.charCodeAt(i + 3) << 24;
}
if (b) v[v.length] = c;
return v;
}
```
</details>
这个简单,就是一些普通的位移操作而已,直接照着葫芦画瓢就行了
咱就用go写一写:
```go
// 神秘的“初步”加密代码,把字符串一四个字符为一组转成神秘的int32数组
func magicEncode(source string, sizeOnLast bool) (result []int32) {
data := []int32(source)
dataLen := len(data)
resultLen := dataLen / 4
if sizeOnLast {
result = make([]int32, resultLen+1)
result[resultLen] = int32(dataLen)
} else {
result = make([]int32, resultLen)
}
for i := 0; i < dataLen; i += 4 {
result[i>>2] = get(data, i, dataLen) | get(data, i+1, dataLen)<<8 | get(data, i+2, dataLen)<<16 | get(data, i+3, dataLen)<<24
}
return result
}
```
因为js里边数组越界也是不会报错的,但是go和大部分语言就不一样了,所以咱们要另外新开一个函数简单的get函数出来,数组越界了就直接返回0而不是报错
```go
func get(data []int32, index int, length int) int32 {
if index >= length {
return 0
} else {
return data[index]
}
}
```
然后就是l函数了:
<details>
<summary>l函数</summary>
```javascript
function l(a, b) {
var d = a.length;
var c = d - 1 << 2;
if (b) {
var m = a[d - 1];
if (m < c - 3 || m > c) return null;
c = m;
}
for (var i = 0; i < d; i++) {
a[i] = String.fromCharCode(a[i] & 0xff, a[i] >>> 8 & 0xff, a[i] >>> 16 & 0xff, a[i] >>> 24 & 0xff);
}
return b ? a.join('').substring(0, c) : a.join('');
}
```
</details>
这个也简单,跟上面的一样照着来就好了,但是有一些问题还是需要注意的
```go
//和上面的是反过来的
func magicDecode(data []int32, sizeOnLast bool) (result []int32) {
dataLength := len(data)
c := dataLength - 1<<2
if sizeOnLast {
m := int(data[dataLength-1])
if m < c-3 || m > c {
return nil
}
c = m
}
for i := 0; i < dataLength; i++ {
result = append(result, data[i]&0xff, uRightShift(data[i], 8)&0xff, uRightShift(data[i], 16)&0xff, uRightShift(data[i], 24)&0xff)
}
if sizeOnLast {
return append(result, int32(c))
} else {
return result
}
}
```
可以看到,这里又有一个新的函数`uRightShift`
```go
// 在go中实现无符号右移(>>>)
func uRightShift(number int32, shift int) int32 {
return int32(uint32(number) >> shift)
}
```
其实就是无符号右移>>>在go中的实现
接下来就是重头戏`encode`函数了:
<details>
<summary>encode函数</summary>
```javascript
function encode(str, key) {
if (str === '') return '';
var v = s(str, true);
var k = s(key, false);
if (k.length < 4) k.length = 4;
var n = v.length - 1,
z = v[n],
y = v[0],
c = 0x86014019 | 0x183639A0,
m,
e,
p,
q = Math.floor(6 + 52 / (n + 1)),
d = 0;
while (0 < q--) {
d = d + c & (0x8CE0D9BF | 0x731F2640);
e = d >>> 2 & 3;
for (p = 0; p < n; p++) {
y = v[p + 1];
m = z >>> 5 ^ y << 2;
m += y >>> 3 ^ z << 4 ^ (d ^ y);
m += k[p & 3 ^ e] ^ z;
z = v[p] = v[p] + m & (0xEFB8D130 | 0x10472ECF);
}
y = v[0];
m = z >>> 5 ^ y << 2;
m += y >>> 3 ^ z << 4 ^ (d ^ y);
m += k[p & 3 ^ e] ^ z;
z = v[n] = v[n] + m & (0xBB390742 | 0x44C6F8BD);
}
return l(v, false);
}
```
</details>
改写成Go之后是这个样子的,一些常数在这里就直接算出来了:
```go
// encode 直接照着原网页的js代码改的,只是能跑
// 因为各种类型转换从理论上来说性能可能会差了点,但是在这里的使用情景几乎是感觉不到的
// 难度其实主要还是在于动态类型语言和静态类型语言在运算时数据溢出的问题不好处理
func encode(str string, key string) []int32 {
v := magicEncode(str, true)
k := magicEncode(key, false)
if len(k) < 4 {
for i := 0; i < (4 - len(k)); i++ {
k = append(k, 0)
}
}
n := int32(len(v) - 1)
z := v[n]
y := v[0]
c := int32(-1640531527)
q := int(math.Floor(float64(6+52/(n+1)))) - 1
d := int32(0)
var e, p int32
// 这里的m用int64(long)是因为后面+=的时候int32会溢出
// 其他变量不用int64是因为直接用int64会导致位运算错误,和js的运算结果不一致
var m int64
for ; q >= 0; q-- {
d = d + c&(-1)
e = uRightShift(d, 2) & 3
for p = 0; p < n; p++ {
y = v[p+1]
m = int64(uRightShift(z, 5) ^ y<<2)
m += int64(uRightShift(y, 3) ^ z<<4 ^ (d ^ y))
m += int64(k[p&3^e] ^ z)
v[p] = v[p] + int32(m&(-1))
z = v[p]
}
y = v[0]
m = int64(uRightShift(z, 5) ^ y<<2)
m += int64(uRightShift(y, 3) ^ z<<4 ^ (d ^ y))
m += int64(k[p&3^e] ^ z)
v[n] = v[n] + int32(m&(-1))
z = v[n]
}
return magicDecode(v, false)
}
```
看着挺吓人,但其实还好,照着思路抄下来就好了,最主要的还是强弱类型语言的不同地方要注意一下
最后,这个请求加密就改写好了。完整的代码已开源在Github上:[lensferno/canti](https://github.com/lensferno/canti)
具体的加密部分的文件在[app/codecs/encode.go](https://github.com/lensferno/canti/blob/main/app/codecs/encode.go)中
就这样啦
> 咚咚咚折腾一番,发现其实之前就有人研究过了:[校园网模拟登录 | xia0ji233's blog](https://xia0ji233.pro/2021/12/08/%E6%A0%A1%E5%9B%AD%E7%BD%91%E6%A8%A1%E6%8B%9F%E7%99%BB%E5%BD%95/) 等等,这个只是其中一个
>
> 这里也有另外一个大佬写的go版本,算法可能会更好,这里的只是照着js改而已
>
> [Debuffxb/srun-go - main.go](https://github.com/Debuffxb/srun-go/blob/master/main.go)