learn.lianglianglee.com/专栏/容器实战高手课/19 容器安全(1):我的容器真的需要privileged权限吗.md.html
2022-05-11 19:04:14 +08:00

717 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<!-- saved from url=(0046)https://kaiiiz.github.io/hexo-theme-book-demo/ -->
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0, user-scalable=no">
<link rel="icon" href="/static/favicon.png">
<title>19 容器安全1我的容器真的需要privileged权限吗.md.html</title>
<!-- Spectre.css framework -->
<link rel="stylesheet" href="/static/index.css">
<!-- theme css & js -->
<meta name="generator" content="Hexo 4.2.0">
</head>
<body>
<div class="book-container">
<div class="book-sidebar">
<div class="book-brand">
<a href="/">
<img src="/static/favicon.png">
<span>技术文章摘抄</span>
</a>
</div>
<div class="book-menu uncollapsible">
<ul class="uncollapsible">
<li><a href="/" class="current-tab">首页</a></li>
</ul>
<ul class="uncollapsible">
<li><a href="../">上一级</a></li>
</ul>
<ul class="uncollapsible">
<li>
<a href="/专栏/容器实战高手课/00 开篇词 一个态度两个步骤,成为容器实战高手.md.html">00 开篇词 一个态度两个步骤,成为容器实战高手.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/01 认识容器:容器的基本操作和实现原理.md.html">01 认识容器:容器的基本操作和实现原理.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/02 理解进程1为什么我在容器中不能kill 1号进程.md.html">02 理解进程1为什么我在容器中不能kill 1号进程.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/03 理解进程2为什么我的容器里有这么多僵尸进程.md.html">03 理解进程2为什么我的容器里有这么多僵尸进程.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/04 理解进程3为什么我在容器中的进程被强制杀死了.md.html">04 理解进程3为什么我在容器中的进程被强制杀死了.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/05 容器CPU1怎么限制容器的CPU使用.md.html">05 容器CPU1怎么限制容器的CPU使用.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/06 容器CPU2如何正确地拿到容器CPU的开销.md.html">06 容器CPU2如何正确地拿到容器CPU的开销.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/07 Load Average加了CPU Cgroup限制为什么我的容器还是很慢.md.html">07 Load Average加了CPU Cgroup限制为什么我的容器还是很慢.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/08 容器内存:我的容器为什么被杀了?.md.html">08 容器内存:我的容器为什么被杀了?.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/09 Page Cache为什么我的容器内存使用量总是在临界点.md.html">09 Page Cache为什么我的容器内存使用量总是在临界点.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/10 Swap容器可以使用Swap空间吗.md.html">10 Swap容器可以使用Swap空间吗.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/11 容器文件系统:我在容器中读写文件怎么变慢了.md.html">11 容器文件系统:我在容器中读写文件怎么变慢了.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/12 容器文件Quota容器为什么把宿主机的磁盘写满了.md.html">12 容器文件Quota容器为什么把宿主机的磁盘写满了.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/13 容器磁盘限速:我的容器里磁盘读写为什么不稳定.md.html">13 容器磁盘限速:我的容器里磁盘读写为什么不稳定.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/14 容器中的内存与IO容器写文件的延时为什么波动很大.md.html">14 容器中的内存与IO容器写文件的延时为什么波动很大.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/15 容器网络我修改了procsysnet下的参数为什么在容器中不起效.md.html">15 容器网络我修改了procsysnet下的参数为什么在容器中不起效.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/16 容器网络配置1容器网络不通了要怎么调试.md.html">16 容器网络配置1容器网络不通了要怎么调试.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/17 容器网络配置2容器网络延时要比宿主机上的高吗.md.html">17 容器网络配置2容器网络延时要比宿主机上的高吗.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/18 容器网络配置3容器中的网络乱序包怎么这么高.md.html">18 容器网络配置3容器中的网络乱序包怎么这么高.md.html</a>
</li>
<li>
<a class="current-tab" href="/专栏/容器实战高手课/19 容器安全1我的容器真的需要privileged权限吗.md.html">19 容器安全1我的容器真的需要privileged权限吗.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/20 容器安全2在容器中我不以root用户来运行程序可以吗.md.html">20 容器安全2在容器中我不以root用户来运行程序可以吗.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/加餐01 案例分析怎么解决海量IPVS规则带来的网络延时抖动问题.md.html">加餐01 案例分析怎么解决海量IPVS规则带来的网络延时抖动问题.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/加餐02 理解perf怎么用perf聚焦热点函数.md.html">加餐02 理解perf怎么用perf聚焦热点函数.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/加餐03 理解ftrace1怎么应用ftrace查看长延时内核函数.md.html">加餐03 理解ftrace1怎么应用ftrace查看长延时内核函数.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/加餐04 理解ftrace2怎么理解ftrace背后的技术tracepoint和kprobe.md.html">加餐04 理解ftrace2怎么理解ftrace背后的技术tracepoint和kprobe.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/加餐05 eBPF怎么更加深入地查看内核中的函数.md.html">加餐05 eBPF怎么更加深入地查看内核中的函数.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/加餐06 BCC入门eBPF的前端工具.md.html">加餐06 BCC入门eBPF的前端工具.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/加餐福利 课后思考题答案合集.md.html">加餐福利 课后思考题答案合集.md.html</a>
</li>
<li>
<a href="/专栏/容器实战高手课/结束语 跳出舒适区,突破思考的惰性.md.html">结束语 跳出舒适区,突破思考的惰性.md.html</a>
</li>
</ul>
</div>
</div>
<div class="sidebar-toggle" onclick="sidebar_toggle()" onmouseover="add_inner()" onmouseleave="remove_inner()">
<div class="sidebar-toggle-inner"></div>
</div>
<script>
function add_inner() {
let inner = document.querySelector('.sidebar-toggle-inner')
inner.classList.add('show')
}
function remove_inner() {
let inner = document.querySelector('.sidebar-toggle-inner')
inner.classList.remove('show')
}
function sidebar_toggle() {
let sidebar_toggle = document.querySelector('.sidebar-toggle')
let sidebar = document.querySelector('.book-sidebar')
let content = document.querySelector('.off-canvas-content')
if (sidebar_toggle.classList.contains('extend')) { // show
sidebar_toggle.classList.remove('extend')
sidebar.classList.remove('hide')
content.classList.remove('extend')
} else { // hide
sidebar_toggle.classList.add('extend')
sidebar.classList.add('hide')
content.classList.add('extend')
}
}
function open_sidebar() {
let sidebar = document.querySelector('.book-sidebar')
let overlay = document.querySelector('.off-canvas-overlay')
sidebar.classList.add('show')
overlay.classList.add('show')
}
function hide_canvas() {
let sidebar = document.querySelector('.book-sidebar')
let overlay = document.querySelector('.off-canvas-overlay')
sidebar.classList.remove('show')
overlay.classList.remove('show')
}
</script>
<div class="off-canvas-content">
<div class="columns">
<div class="column col-12 col-lg-12">
<div class="book-navbar">
<!-- For Responsive Layout -->
<header class="navbar">
<section class="navbar-section">
<a onclick="open_sidebar()">
<i class="icon icon-menu"></i>
</a>
</section>
</header>
</div>
<div class="book-content" style="max-width: 960px; margin: 0 auto;
overflow-x: auto;
overflow-y: hidden;">
<div class="book-post">
<p id="tip" align="center"></p>
<div><h1>19 容器安全1我的容器真的需要privileged权限吗</h1>
<p>你好,我是程远。从今天这一讲,我们进入到了容器安全的模块。</p>
<p>容器安全是一个很大的话题,容器的安全性很大程度是由容器的架构特性所决定的。比如容器与宿主机共享 Linux 内核,通过 Namespace 来做资源的隔离,通过 shim/runC 的方式来启动等等。</p>
<p>这些容器架构特性在你选择使用容器之后作为使用容器的用户其实你已经没有多少能力去对架构这个层面做安全上的改动了。你可能会说用Kata Container、gVisor 就是安全“容器”了。不过Kata 或者 gVisor 只是兼容了容器接口标准,而内部的实现完全是另外的技术了。</p>
<p>那么对于使用容器的用户,在运行容器的时候,在安全方面可以做些什么呢?我们主要可以从这两个方面来考虑:第一是赋予容器合理的 capabilities第二是在容器中以非 root 用户来运行程序。</p>
<p>为什么是这两点呢?我通过两讲的内容和你讨论一下,这一讲我们先来看容器的 capabilities 的问题。</p>
<h2>问题再现</h2>
<p>刚刚使用容器的同学,往往会发现用缺省 docker run的方式启动容器后在容器里很多操作都是不允许的即使是以 root 用户来运行程序也不行。</p>
<p>我们用下面的例子来重现一下这个问题。我们先运行make image 做个容器镜像,然后运行下面的脚本:</p>
<pre><code># docker run --name iptables -it registry/iptables:v1 bash
[<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="d8aab7b7ac98e8bae0e0bceeece0eee9ece1">[email&#160;protected]</a> /]# iptables -L
iptables v1.8.4 (nf_tables): Could not fetch rule set generation id: Permission denied (you must be root)
[<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="3a4855554e7a0a5802025e0c0e020c0b0e03">[email&#160;protected]</a> /]# id
uid=0(root) gid=0(root) groups=0(root)
</code></pre>
<p>在这里,我们想在容器中运行 iptables 这个命令,来查看一下防火墙的规则,但是执行命令之后,你会发现结果输出中给出了&quot;Permission denied (you must be root)&quot;的错误提示,这个提示要求我们用 root 用户来运行。</p>
<p>不过在容器中,我们现在已经是以 root 用户来运行了,么为什么还是不可以运行&quot;iptables&quot;这条命令呢?</p>
<p>你肯定会想到,是不是容器中又做了别的权限限制?如果你去查一下资料,就会看到启动容器有一个&quot;privileged&quot;的参数。我们可以试一下用上这个参数没错我们用了这个参数之后iptables 这个命令就执行成功了。</p>
<pre><code># docker stop iptables;docker rm iptables
iptables
iptables
# docker run --name iptables --privileged -it registry/iptables:v1 bash
[<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="067469697246323237303e6032643f643432">[email&#160;protected]</a> /]# iptables -L
Chain INPUT (policy ACCEPT)
target prot opt source destination
Chain FORWARD (policy ACCEPT)
target prot opt source destination
Chain OUTPUT (policy ACCEPT)
target prot opt source destination
</code></pre>
<p>看上去,我们用了一个配置参数就已经解决了问题,似乎很容易。不过这里我们可以进一步想想,用&quot;privileged&quot;参数来解决问题,是不是一个合理的方法呢?用它会有什么问题吗?</p>
<p>要回答这些问题,我们先来了解一下&quot;privileged&quot;是什么意思。从 Docker 的代码里,我们可以看到,如果配置了 privileged 的参数的话,就会获取所有的 capabilities那什么是 capabilities 呢?</p>
<pre><code> if ec.Privileged {
p.Capabilities = caps.GetAllCapabilities()
}
</code></pre>
<h2>基本概念</h2>
<h3>Linux capabilities</h3>
<p>要了解 Linux capabilities 的定义,我们可以先查看一下&quot;Linux Programmer's Manual&quot;中关于Linux capabilities的描述。</p>
<p>在 Linux capabilities 出现前,进程的权限可以简单分为两类,第一类是特权用户的进程(进程的有效用户 ID 是 0简单来说你可以认为它就是 root 用户的进程),第二类是非特权用户的进程(进程的有效用户 ID 是非 0可以理解为非 root 用户进程)。</p>
<p>特权用户进程可以执行 Linux 系统上的所有操作,而非特权用户在执行某些操作的时候就会被内核限制执行。其实这个概念,也是我们通常对 Linux 中 root 用户与非 root 用户的理解。</p>
<p>从 kernel 2.2 开始Linux 把特权用户所有的这些“特权”做了更详细的划分,这样被划分出来的每个单元就被称为 capability。</p>
<p>所有的 capabilities 都在Linux capabilities的手册列出来了你也可以在内核的文件capability.h中看到所有 capabilities 的定义。</p>
<p>对于任意一个进程,在做任意一个特权操作的时候,都需要有这个特权操作对应的 capability。</p>
<p>比如说,运行 iptables 命令,对应的进程需要有 CAP_NET_ADMIN 这个 capability。如果要 mount 一个文件系统,那么对应的进程需要有 CAP_SYS_ADMIN 这个 capability。</p>
<p>我还要提醒你的是CAP_SYS_ADMIN 这个 capability 里允许了大量的特权操作,包括文件系统,交换空间,还有对各种设备的操作,以及系统调试相关的调用等等。</p>
<p>在普通 Linux 节点上,非 root 用户启动的进程缺省没有任何 Linux capabilities而 root 用户启动的进程缺省包含了所有的 Linux capabilities。</p>
<p>我们可以做个试验,对于 root 用户启动的进程,如果把 CAP_NET_ADMIN 这个 capability 移除,看看它是否还可以运行 iptables。</p>
<p>在这里我们要用到capsh这个工具对这个工具不熟悉的同学可以查看超链接。接下来我们就用 capsh 执行下面的这个命令:</p>
<pre><code># sudo /usr/sbin/capsh --keep=1 --user=root --drop=cap_net_admin -- -c './iptables -L;sleep 100'
Chain INPUT (policy ACCEPT)
target prot opt source destination
Chain FORWARD (policy ACCEPT)
target prot opt source destination
Chain OUTPUT (policy ACCEPT)
target prot opt source destination
iptables: Permission denied (you must be root).
</code></pre>
<p>这时候,我们可以看到即使是 root 用户,如果把&quot;CAP_NET_ADMIN&quot;给移除了,那么在执行 iptables 的时候就会看到&quot;Permission denied (you must be root).&quot;的提示信息。</p>
<p>同时,我们可以通过 /proc 文件系统找到对应进程的 status这样就能确认进程中的 CAP_NET_ADMIN 是否已经被移除了。</p>
<pre><code># ps -ef | grep sleep
root 22603 22275 0 19:44 pts/1 00:00:00 sudo /usr/sbin/capsh --keep=1 --user=root --drop=cap_net_admin -- -c ./iptables -L;sleep 100
root 22604 22603 0 19:44 pts/1 00:00:00 /bin/bash -c ./iptables -L;sleep 100
# cat /proc/22604/status | grep Cap
CapInh: 0000000000000000
CapPrm: 0000003fffffefff
CapEff: 0000003fffffefff
CapBnd: 0000003fffffefff
CapAmb: 0000000000000000
</code></pre>
<p>运行上面的命令查看 /proc//status 里 Linux capabilities 的相关参数之后,我们可以发现,输出结果中包含 5 个 Cap 参数。</p>
<p>这里我给你解释一下, 对于当前进程,直接影响某个特权操作是否可以被执行的参数,是&quot;CapEff&quot;,也就是&quot;Effective capability sets&quot;,这是一个 bitmap每一个 bit 代表一项 capability 是否被打开。</p>
<p>在 Linux 内核capability.h里把 CAP_NET_ADMIN 的值定义成 12所以我们可以看到&quot;CapEff&quot;的值是&quot;0000003fffffefff&quot;,第 4 个数值是 16 进制的&quot;e&quot;,而不是 f。</p>
<p>这表示 CAP_NET_ADMIN 对应的第 12-bit 没有被置位了0xefff = 0xffff &amp; (~(1 &lt;&lt; 12))),所以这个进程也就没有执行 iptables 命令的权限了。</p>
<p>对于进程 status 中其他几个 capabilities 相关的参数,它们还需要和应用程序文件属性中的 capabilities 协同工作,这样才能得到新启动的进程最终的 capabilities 参数的值。</p>
<p>我们看下面的图,结合这张图看后面的讲解:</p>
<p><img src="assets/906a996776f84d8f856cc7f62589095c.jpeg" alt="img" /></p>
<p>如果我们要新启动一个程序,在 Linux 里的过程就是先通过 fork() 来创建出一个子进程,然后调用 execve() 系统调用读取文件系统里的程序文件,把程序文件加载到进程的代码段中开始运行。</p>
<p>就像图片所描绘的那样,这个新运行的进程里的相关 capabilities 参数的值,是由它的父进程以及程序文件中的 capabilities 参数值计算得来的。</p>
<p>具体的计算过程你可以看Linux capabilities的手册中的描述也可以读一下网上的这两篇文章</p>
<p>Capabilities: Why They Exist and How They Work</p>
<p>Linux Capabilities in Practice</p>
<p>我就不对所有的进程和文件的 capabilities 集合参数和算法挨个做解释了,感兴趣的话你可以自己详细去看看。</p>
<p>这里你只要记住最重要的一点,文件中可以设置 capabilities 参数值,并且这个值会影响到最后运行它的进程。比如,我们如果把 iptables 的应用程序加上 CAP_NET_ADMIN 的 capability那么即使是非 root 用户也有执行 iptables 的权限了。</p>
<pre><code>$ id
uid=1000(centos) gid=1000(centos) groups=1000(centos),10(wheel)
$ sudo setcap cap_net_admin+ep ./iptables
$ getcap ./iptables
./iptables = cap_net_admin+ep
$./iptables -L
Chain INPUT (policy ACCEPT)
target prot opt source destination
Chain FORWARD (policy ACCEPT)
target prot opt source destination
DOCKER-USER all -- anywhere anywhere
DOCKER-ISOLATION-STAGE-1 all -- anywhere anywhere
ACCEPT all -- anywhere anywhere ctstate RELATED,ESTABLISHED
DOCKER all -- anywhere anywhere
ACCEPT all -- anywhere anywhere
ACCEPT all -- anywhere anywhere
</code></pre>
<p>好了,关于 Linux capabilities 的内容到这里我们就讲完了,其实它就是把 Linux root 用户原来所有的特权做了细化,可以更加细粒度地给进程赋予不同权限。</p>
<h2>解决问题</h2>
<p>我们搞懂了 Linux capabilities 之后,那么对 privileged 的容器也很容易理解了。Privileged 的容器也就是允许容器中的进程可以执行所有的特权操作。</p>
<p>因为安全方面的考虑,容器缺省启动的时候,哪怕是容器中 root 用户的进程,系统也只允许了 15 个 capabilities。这个你可以查看runC spec 文档中的 security 部分,你也可以查看容器 init 进程 status 里的 Cap 参数,看一下容器中缺省的 capabilities。</p>
<pre><code># docker run --name iptables -it registry/iptables:v1 bash
[<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="84f6ebebf0c4e1b1b0b2bdb0b2b1b6e5b0b6">[email&#160;protected]</a> /]# cat /proc/1/status |grep Cap
CapInh: 00000000a80425fb
CapPrm: 00000000a80425fb
CapEff: 00000000a80425fb
CapBnd: 00000000a80425fb
CapAmb: 0000000000000000
</code></pre>
<p>我想提醒你,当我们发现容器中运行某个程序的权限不够的时候,并不能“偷懒”把容器设置为&quot;privileged&quot;,也就是把所有的 capabilities 都赋予了容器。</p>
<p>因为容器中的权限越高,对系统安全的威胁显然也是越大的。比如说,如果容器中的进程有了 CAP_SYS_ADMIN 的特权之后,那么这些进程就可以在容器里直接访问磁盘设备,直接可以读取或者修改宿主机上的所有文件了。</p>
<p>所以,在容器平台上是基本不允许把容器直接设置为&quot;privileged&quot;的,我们需要根据容器中进程需要的最少特权来赋予 capabilities。</p>
<p>我们结合这一讲开始的例子来说说。在开头的例子中,容器里需要使用 iptables。因为使用 iptables 命令,只需要设置 CAP_NET_ADMIN 这个 capability 就行。那么我们只要在运行 Docker 的时候,给这个容器再多加一个 NET_ADMIN 参数就可以了。</p>
<pre><code># docker run --name iptables --cap-add NET_ADMIN -it registry/iptables:v1 bash
[<a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="b0c2dfdfc4f0d3d6d5d4d6818284d4d3d681">[email&#160;protected]</a> /]# iptables -L
Chain INPUT (policy ACCEPT)
target prot opt source destination
Chain FORWARD (policy ACCEPT)
target prot opt source destination
Chain OUTPUT (policy ACCEPT)
target prot opt source destination
</code></pre>
<h2>重点小结</h2>
<p>这一讲我们主要学习了如何给容器赋予合理的 capabilities。</p>
<p>那么,我们自然需要先来理解什么是 Linux capabilities。其实 Linux capabilities 就是把 Linux root 用户原来所有的特权做了细化,可以更加细粒度地给进程赋予不同权限。</p>
<p>对于 Linux 中的每一个特权操作都有一个对应的 capability对于一个 capability有的对应一个特权操作有的可以对应很多个特权操作。</p>
<p>每个 Linux 进程有 5 个 capabilities 集合参数,其中 Effective 集合里的 capabilities 决定了当前进程可以做哪些特权操作,而其他集合参数会和应用程序文件的 capabilities 集合参数一起来决定新启动程序的 capabilities 集合参数。</p>
<p>对于容器的 root 用户,缺省只赋予了 15 个 capabilities。如果我们发现容器中进程的权限不够就需要分析它需要的最小 capabilities 集合,而不是直接赋予容器&quot;privileged&quot;</p>
<p>因为&quot;privileged&quot;包含了所有的 Linux capabilities, 这样&quot;privileged&quot;就可以轻易获取宿主机上的所有资源,这会对宿主机的安全产生威胁。所以,我们要根据容器中进程需要的最少特权来赋予 capabilities。</p>
<h2>思考题</h2>
<p>你可以查看一下你的 Linux 系统里 ping 程序文件有哪些 capabilities看看有什么办法能让 Linux 普通用户没有执行 ping 的能力。</p>
<p>欢迎你在留言区和我交流互动。如果学完这一讲让你有所收获,也欢迎转发给你的同事、或者朋友,一起交流探讨容器安全的问题。</p>
</div>
</div>
<div>
<div style="float: left">
<a href="/专栏/容器实战高手课/18 容器网络配置3容器中的网络乱序包怎么这么高.md.html">上一页</a>
</div>
<div style="float: right">
<a href="/专栏/容器实战高手课/20 容器安全2在容器中我不以root用户来运行程序可以吗.md.html">下一页</a>
</div>
</div>
</div>
</div>
</div>
</div>
<a class="off-canvas-overlay" onclick="hide_canvas()"></a>
</div>
<script data-cfasync="false" src="/cdn-cgi/scripts/5c5dd728/cloudflare-static/email-decode.min.js"></script><script defer src="https://static.cloudflareinsights.com/beacon.min.js/v652eace1692a40cfa3763df669d7439c1639079717194" integrity="sha512-Gi7xpJR8tSkrpF7aordPZQlW2DLtzUlZcumS8dMQjwDHEnw9I7ZLyiOj/6tZStRBGtGgN6ceN6cMH8z7etPGlw==" data-cf-beacon='{"rayId":"709977aa6f813cfa","version":"2021.12.0","r":1,"token":"1f5d475227ce4f0089a7cff1ab17c0f5","si":100}' crossorigin="anonymous"></script>
</body>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-NPSEEVD756"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag('js', new Date());
gtag('config', 'G-NPSEEVD756');
var path = window.location.pathname
var cookie = getCookie("lastPath");
console.log(path)
if (path.replace("/", "") === "") {
if (cookie.replace("/", "") !== "") {
console.log(cookie)
document.getElementById("tip").innerHTML = "<a href='" + cookie + "'>跳转到上次进度</a>"
}
} else {
setCookie("lastPath", path)
}
function setCookie(cname, cvalue) {
var d = new Date();
d.setTime(d.getTime() + (180 * 24 * 60 * 60 * 1000));
var expires = "expires=" + d.toGMTString();
document.cookie = cname + "=" + cvalue + "; " + expires + ";path = /";
}
function getCookie(cname) {
var name = cname + "=";
var ca = document.cookie.split(';');
for (var i = 0; i < ca.length; i++) {
var c = ca[i].trim();
if (c.indexOf(name) === 0) return c.substring(name.length, c.length);
}
return "";
}
</script>
</html>