0%

背景介绍

之前 Blog 是放在 Github Page 上托管的,大致步骤如下:

  1. 在 Github 上提交博客原始文件在 source 分支上;
  2. Travis-ci 设置为 Node 环境,并使用 Hexo 将 source 分支下的文件生成静态网页,再强制提交到 master 分支;
  3. 由于 Github Page 绑定域名不支持 HTTPS, 又使用了 Cloudflare 作为 CDN 全站加速,并强制 HTTPS 访问。

这种方式 0 成本部署网站的方式,不仅免备案,还满足了我全站 HTTPS 的执念,让我很满意。唯一美中不足的就是国内访问速度太慢。为了改善访问速度,我又尝试了将全站加速切换到阿里云的海外版全站加速服务,但是效果还行不行,国内访问时快时慢,很不稳定。就在研究阿里云的过程中,发现阿里云 OSS 支持部署静态网站,而且香港区域速度很快、很稳定,绑定域名免备案,于是就有了接下来的折腾。

在正式阅读本文章前,你需要了解 Hexo、Travis-ci 是什么,有啥功能;阿里云 OSS、函数式计算、网关服务如何开通、如何使用。否则可能读起来会有些云里雾里的感觉。

文章中的一些配置都是摘录的,可能不完整,你可以参考我的博客配置 https://github.com/liuhu/liuhu.github.io

将 Blog 迁移至阿里 OSS

流程介绍

先整体介绍一下部署流程, 如下图:

定制修改 Hexo 支持静态部署

由于 OSS 的资源 必须以绝对路径的方式进行访问。但是 Hexo 生成的静态页面中,部分页面的超链接路径去缺少了具体文件名,会导致访问出现 404 错误。比如默认访问 About me 的地址为 https://www.liuhu.me/about/ 现在需要改为 https://www.liuhu.me/about/index.html。即要将所有生成的静态页面中的超链接路径后加上 index.html 才能正常访问。有如下几点需要修改:

  1. 将文章标题修改为 .html 为结尾的形式。修改根路径下的 _config.yml 文件:

    1
    2
    3
    permalink: :title.html
    # 或者如下方式
    # permalink: :year/:month/:day/:title/index.html
  2. 将导航栏中,标签超链接结尾添加 index.html 。修改 themes/next/_config.yml

    1
    2
    3
    4
    5
    menu:
    about: /about/index.html || user
    tags: /tags/index.html || tags
    categories: /categories/index.html || th
    archives: /archives/index.html || archive
  3. 将文章中标签的超链接结尾增加 index.html 。修改 themes/next/layout/_macro/post.swig :

    1
    2
    3
    4
    5
    6
     <!-- 修改第128行-->
    #}<a href="{{ url_for(cat.path) }}index.html" itemprop="url" rel="index">{#

    <!-- 修改第366行-->
    <a href="{{ url_for(tag.path) }}index.html" rel="tag"># {{ tag.name }}</a>

  4. 将标签云的超链接结尾增加 index.html 。修改 Hexo 插件源码 /node_modules/hexo/lib/plugins/helper/list_tags.js

    1
    2
    // 修改第18行
    const suffix = options.suffix || 'index.html';
  5. 将文章分类的超链接结尾增加 index.html 。修改 Hexo 插件源码 /node_modules/hexo/lib/plugins/helper/list_categories.js

    1
    2
    // 修改第21行
    const suffix = options.suffix || 'index.html';
  6. 将分页超链接结尾增加 index.html 。修改 Hexo 插件源码 /node_modules/hexo/lib/plugins/helper/paginator.js

    1
    2
    3
    4
    // 修改第24行
    function link(i) {
    return self.url_for(i === 1 ? base : base + format.replace('%d', i)) + 'index.html';
    }

配置 Travis CI 持续集成、持续部署

Travis CI 的部署配置如下,你可以参考我的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
language: node_js
node_js:
- '10.16.0'

# 创建cache目录 ,防止每次编译打包都下载
cache:
directories:
- node_modules
- tools

before_install:
- npm install
- git config --global push.default matching
- git config --global user.name "liuhu"
- git config --global user.email "[email protected]"
- cd ./tools
## 下载阿里云 OSS Linux 客户端
- wget -nc https://liuhu-img.oss-cn-hongkong.aliyuncs.com/common/deploy/ossutil64
- chmod 755 ossutil64
## 配置 OSS 客户端, 包括 OSS 地址,accessKeyID accessKeySecret,使用方式可以参考 https://www.alibabacloud.com/help/zh/doc-detail/50452.htm
- ./ossutil64 config -e "${OSS_ENDPOINT}" -i "${OSS_AKI}" -k "${OSS_AKS}"
## 替换 Hexo 插件,使其支持静态部署
- cd ../node_modules/hexo/lib/plugins/helper
- wget -N https://liuhu-img.oss-cn-hongkong.aliyuncs.com/common/deploy/list_categories.js
- wget -N https://liuhu-img.oss-cn-hongkong.aliyuncs.com/common/deploy/list_tags.js
- wget -N https://liuhu-img.oss-cn-hongkong.aliyuncs.com/common/deploy/paginator.js
- wget -N https://liuhu-img.oss-cn-hongkong.aliyuncs.com/common/deploy/tagcloud.js
- cd -
- cd ..

script:
- hexo clean
- hexo generate

after_success:
- cd ./public
- git init
- git add --all .
- git commit -m "Travis CI Auto Builder"
- git push --force --quiet "https://${GITHUB_TOKEN}@${GH_REF}" master

## 打包静态文件
- zip -r blog.zip ./*
## 删除OSS上旧文件
- ../tools/ossutil64 rm oss://liuhu-blog/ -r -f
## 上传zip文件
- ../tools/ossutil64 cp -f blog.zip oss://liuhu-blog/

env:
global:
- GH_REF: github.com/liuhu/liuhu.github.io.git

使用阿里云函数式计算解压

先通过阿里OSS中的函数式计算创建 ZIP包解压 函数式计算,简化触发器配置和权限授权的配置。然后在 编辑 默认的函数代码,实现在根目录的解压文件。

和默认代码的区别就在于注释的那几个行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import helper
import oss2, json
import os
import logging
import chardet

logging.getLogger("oss2.api").setLevel(logging.ERROR)
logging.getLogger("oss2.auth").setLevel(logging.ERROR)

LOGGER = logging.getLogger()

def handler(event, context):
evt_lst = json.loads(event)
creds = context.credentials
auth=oss2.StsAuth(
creds.access_key_id,
creds.access_key_secret,
creds.security_token)

evt = evt_lst['events'][0]
bucket_name = evt['oss']['bucket']['name']
endpoint = 'oss-' + evt['region'] + '-internal.aliyuncs.com'
bucket = oss2.Bucket(auth, endpoint, bucket_name)
object_name = evt['oss']['object']['key']

file_type = os.path.splitext(object_name)[1]

if file_type != ".zip":
raise RuntimeError('{} filetype is not zip'.format(object_name))

LOGGER.info("start to decompress zip file = {}".format(object_name))

lst = object_name.split("/")
zip_name = lst[-1]
## 删除 newKey 相关的代码,为了让压缩包在根目录解压

# PROCESSED_DIR = os.environ.get("PROCESSED_DIR", "")
# if PROCESSED_DIR and PROCESSED_DIR[-1] != "/":
# PROCESSED_DIR += "/"
# newKey = PROCESSED_DIR + zip_name
zip_fp = helper.OssStreamFileLikeObject(bucket, object_name)

# newKey = newKey.replace(".zip", "/")

with helper.zipfile_support_oss.ZipFile(zip_fp) as zip_file:
for name in zip_file.namelist():
with zip_file.open(name) as file_obj:
try:
name = name.encode(encoding='cp437')
except:
name = name.encode(encoding='utf-8')

detect = chardet.detect( (name*100)[0:100] )
confidence = detect["confidence"]
if confidence > 0.8:
try:
name = name.decode(encoding=detect["encoding"])
except:
name = name.decode(encoding='gb2312')
else:
name = name.decode(encoding="gb2312")
## 删除 newKey
bucket.put_object(name, file_obj)

使用阿里云网关配置根域名301跳转

创建阿里云API网关,并绑定根域名地址。DNS解析中增加根域名CNAME解析记录,指向到API网关地址。
访问流程如下:

使用网关提供的 Mock 功能,实现根域名 301 跳转。配置如下:

总结

现在博客发访问速度比之前快多了,由于博客的访问量非常少,OSS 的资源占用费也没有产生过,也算是 0 成本吧。

目前看还有两个地方感觉不完美:

  1. 访问 http://www.liuhu.me 不能跳转到 https://www.liuhu.me,目前只实现了根域名的跳转。
  2. 为了解决 OSS 静态资源访问,修改了 Hexo 的插件,可能为后面的插件版本升级带来麻烦。目前看应该影响不大,因为那几个插件都很稳定,好久不更新了。

这个两个问题,目前我有个解决思路,就是使用 API 网关 + 函数式计算实现。 API 网关将请求转发到函数式计算服务中,函数式计算通过分析 Request 请求信息判断,如果不是 https 访问则返回 301 状态码和跳转地址,这样解决了第一个问题。如果是 https 访问,则将请求地址按照规则连接上 index.html 转发到后端 OSS 服务上去,然后将 OSS 服务的返回结果原样返回,这样解决了第二个问题。这个方案后续有空再折腾吧。

背景

我司安排每周四 19:00 ~ 21:00 组织一次羽毛球活动。我们定的场馆是支持手机 APP 预订的,在办卡的时候得知场馆实在是太火爆了,每天早上 6 点开抢,热门时段的场地在 4 秒之内绝对秒杀完。为了保证活动每次都能够正常组织,我准备为大家写个小工具,实现高成功率的自动抢购。程序员就是有个好处,遇到问题会想着制造工具完成。
考虑到安全性问题,文章一些私密信息都隐匿了,这里主要是介绍了我的思路,供大家参考。

抓包分析

通过分析手机 APP 和 服务端的请求响应报文,来了解需要模拟的报文结构,最终实现自动下单抢购。这里介绍一款神器 Burp Suite,他是一款安全渗透测试工具,可以拦截、分析、修改、重放报文,还支持 HTTPS ,这些功能我屡试不爽。

Step1. 设置代理
Burp Suite 中配置代理信息,并在手机的网络设置中配置该代理信息,使手机上产生的请求响应经过 Burp Suite

Step2. 分析报文
如下是订购场地的请求和响应报文。
请求是Json格式的,由 header 和 body 两部分组成;header中包含了一些终端信息,还有程序员特别敏感的 accessToken 字段。这个貌似是认证用的,经过修改报文的 accessToken 内容,然后重放测试,订单依旧可以创建成功,基本可以说明 accessToken 对于安全认证是一点作用都没有。
麻烦是 body 和 Response 的内容都是加密的,看不出一点规律,仅能猜测出应该使用的是对称加密算法。
Request:

Response:

逆向工程

现在问题的关键就在于解密算法了,唯一的办法就是破解手机客户端,通过逆向工程,反编译出其加解密算法。
Step1. Android apk 逆向工程

a. 解压 apk 文件,获取 classes.dex 文件
b. 使用 dex2jar 逆向工程得到 jar 文件 d2j-dex2jar.sh classes.dex

Step2. 使用 JD-GUI 反编译 jar 包并导出所有反编译代码文件,然后在IDE中打开,方便搜索。
Step3. 从请求 URL 为入口,找出加解密算法。

最终确认了加解密算法是通过ASE对称加密实现的,还有秘钥是网站的域名。

解密的订购请求信息如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
{
"goodOrderList": [
{
"cardCode": "",
"goodCount": "1",
"goodCutPrice": "60",
"goodFkid": "3871728170121????",
"goodId": "20387172816755710490????",
"goodName": "06月03日~3号场-20:00~21:00",
"goodPrice": "60",
"goodType": "1",
"venueSaleId": "9d84b1a709b64f72bdf543aedf50????"
}
],
"isCollect": "0",
"orgCode": "fe9d896bf6ad4ff88d69d7c477c9????",
"paySource": "1",
"sourceFkId": "6049785525????",
"sourceName": "游泳馆羽毛球",
"totalAmount": "60.0",
"totalCutAmount": "60.0",
"userId": "34693????6029????"
}

进行到这里,整个项目从 APP 端到服务端的交互方式已经基本清晰,安全性校验几乎没有,这样小程序实现起来会简单很多。

订购流程梳理

最关键的问题解决了,接下来就是进行一次完整订购流程,分析哪些请求需要模拟。
如下时序图是简化版的订购流程:

设计、编码

  • 使用 Spring Schedule + JUC ThreadPoolExecutor 实现定时并发抢购

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    /**
    * 处理抢购任务
    */
    @Scheduled(zone = "Asia/Shanghai", cron = "${schedule.autoPanicOrder.cron}")
    public void panicScheduleTask() {
    taskMap.values().forEach(
    x -> {
    // 抢购日期提前两天
    String dateStr = LocalDateTime.now().plusDays(2).format(DATE_TIME_FORMATTER);
    if (dateStr.equals(x.getDate())) {
    threadPoolExecutor.execute(() -> panic(x));
    } else {
    log.info("还未到达抢购时间, x = {}", x);
    }
    }
    );
    }
  • 使用 ReentrantLock 实现对任务细粒度锁控制,防止同一任务在短时间内并发执行

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    /**
    * 执行抢购任务
    * @param taskDto
    */
    private void panicTask(OrderTaskDto taskDto) {
    ReentrantLock lock = taskLockMap.get(taskDto.getOrderTaskId());
    try {
    if (lock.tryLock(1, TimeUnit.SECONDS)) {
    // 抢购逻辑
    }
    } catch (Exception e) {
    log.error("抢购任务异常, taskDto = {}, e = {}", taskDto, e);
    } finally {
    if (lock.isHeldByCurrentThread()) {
    lock.unlock();
    }
    }
    }
  • 使用 Google Guava Cache 实现对场地信息的缓存, 减少抢购时的请求次数,减少在网络IO上的等待

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
     /**
    * 缓存场地订单信息
    */
    private Cache<String, Map<Integer, List<SellOrderDto>>> orderCache = CacheBuilder.newBuilder()
    .expireAfterAccess(60, TimeUnit.SECONDS)
    .build();

    /**
    * 根据时间查询场地订单列表
    * @param queryDto
    * @return
    */
    public Map<Integer, List<SellOrderDto>> getVenueOrder(VenueOrderQueryDto queryDto) {
    try {
    return orderCache.get(buildOrderKey(queryDto), () -> {
    VenueOrderResponseDto responseDto = exchange(queryDto, Constants.QUERY_VENUE_SELL_ORDER, VenueOrderResponseDto.class);
    if (null == responseDto || null == responseDto.getBody()) {
    return null;
    }
    Map<Integer, List<SellOrderDto>> orderMap = responseDto.getBody().getSellOrderMap();
    if (null == orderMap || orderMap.isEmpty()) {
    return null;
    }
    return orderMap;
    });
    } catch (Exception e) {
    log.error("场地订单信息获取异常, queryDto = {}, e = {}", queryDto, e.getMessage());
    return null;
    }
    }
  • 封装请求和响应的加解密逻辑, 实现请求自动加解密(ASE对称加密)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    @Slf4j
    public abstract class TransportBaseService {
    private static final JsonMapper JSON_MAPPER = JsonMapper.alwaysMapper();
    private static final RequestHeaderDto HEADER = new RequestHeaderDto();
    private static final AtomicLong ADDER = new AtomicLong();
    /**
    * 模拟20个终端
    */
    private static final Map<Long, String> DEVICE_ID_MAP = LongStream.rangeClosed(0, 19).sorted()
    .collect(HashMap::new, (m, v) -> m.put(v, UUID.randomUUID().toString()), HashMap::putAll);

    @Autowired
    private RestTemplate restTemplate;


    public <T> T exchange(Object request, String requestUrl, Class<T> type) {
    RequestDto requestDto = new RequestDto();
    requestDto.setBody(EncryptionUtils.encrypt2Aes(JSON_MAPPER.toJson(request), GetKeyUtils.getKey()));
    // 突破流控限制
    HEADER.setDeviceId("8" + DEVICE_ID_MAP.get(ADDER.incrementAndGet() % (DEVICE_ID_MAP.size())));
    requestDto.setHeader(HEADER);

    try {
    long startTime = System.currentTimeMillis();
    ResponseEntity<String> response = restTemplate.postForEntity(requestUrl, requestDto, String.class);
    long endTime = System.currentTimeMillis();
    log.info("请求: {}, 耗时: {}ms", requestUrl, endTime - startTime);
    String responseJsonStr = EncryptionUtils.decodeFromAes(response.getBody(), GetKeyUtils.getKey());
    return JSON_MAPPER.fromJson(responseJsonStr, type);
    } catch (RestClientException e) {
    log.error("接口调用异常, requestUrl = {}, e = {}", requestUrl, e.getMessage());
    throw new RuntimeException("接口调用异常!");
    }
    }
    }

部署、实测

部署

  • 部署的服务器开启 NTP 时间同步
  • 使用 hosts 配置域名和 IP 的对应关系,减少请求时域名解析的时间

实测
在实测中效果非常好,根据配置的场地优先级列表,每次都可以抢到心仪的场地。用了快一个月了,可以保证想要那个场地就能抢到那个场地的效果,成功率目前是 100% 。机器抢和人肉抢就是不一样哇。

总结感悟

本次实战发现如下几个安全性问题:

  1. APP 端和服务端通信不是 HTTPS 安全通信, 很容易截获修改
  2. 验证码在 APP 本地校验,非服务端验证
  3. 服务端流控限制规则太简单,仅通过请求中的 deviceId 识别用户, 只需要在请求中 deviceId 改成 UUID 可轻松突破限制
  4. AES 的 PSK 是写死在 APP 中,没有进行安全加固
  5. APP 端是不可以跨场地预定的,从接口设计上看貌似可以 (不敢随意尝试)
  6. 创建订单,价格是在请求中传入的,貌似可以改价(不敢随意尝试)

安全问题无小事,开发的时候一定要在安全方面的问题考虑周全,否则很容易出大问题。现在很多初创公司都把精力放在业务发展上,在信息安全上的投入非常少,我司也不例外,其实是需要有一定的投入的,防止在事后救火,到时候损失就更大了。就比方是最近出现的安全事件,’易到用车’ 的核心数据都被加密了,业务系统直接瘫痪。企业要是死在网络安全上,实在是可惜呀。

这个几天一直在学习”极客时间”中购买的专栏课程,有好几位大师都提到写作的重要性。我也认识到了这一点,打算长期写下去。下面是我目前的一些心得,后续有新的心得再补充。

  • 写作可以帮助你梳理思路、总结问题,甚至可以启发新的灵感。
  • 写作是对问题的抽象和实现,抽象是指文章的构思和框架,实现是指围绕框架的具体内容。这里即有全局又有细节,写作过程会迫使自己把问题想的更全面、更深入。在这个过程中可能会发现自己的不足,然后又迫使你继续学习。就在写上一篇关于Akka总结性的文章中,我就发现自己还有许多不懂,和理解不深入的地方,为日后的学习提供了方向。
  • 写作可以让我们将零散的知识结构化。
  • 当我们将想法转换为文字时,会发现缺失很多细节,有利于我们发现问题,并强化知识。

附:阮老师的 《中文技术文档的写作规范》