博客

  • 我要看动画! | 公网samba服务抗干扰指南

    在过去一段相当长的时间里,我都在使用移动的宽带IPv6访问家中的移动网络,用Samba服务(工作在标准端口445,伏笔之一)观看硬盘上的动画,虽然两地距离上千公里,延迟和速度也都十分不错,相当稳定。

    后来由于一些原因我开始使用联通的宽带,光猫没有开启IPv6,无线质量也十分不理想,算是和家里的网络服务失联了一段时间。后来换了新的路由器,顺带把光猫改为了桥接,成功拿到了IPv6地址,就在我以为终于可以开始重新看动画片的时候,新的问题已经出现。

    PING延迟优秀,Web服务(工作在443端口,伏笔之二)速度正常,可Samba无论如何无法连接,甚至是“找不到网络名”这样莫名其妙的问题。由于事出诡异,我随即开始排查。

    首先排除本地设备的问题,用电脑连接手机流量(中国移动,伏笔之三),Samba服务正常,回到原来的网络,无法连接。我本以为是OpenWrt关闭了445端口,但查看后发现并没有这样的规则。索性把防火墙整个关掉也没有效果。至此,基本可以确认不是本地设备和网络的原因。

    遇事不决,开始上网求助。一通搜索无果,猛然想起来445端口这些年在相关报道上的“高风亮节”,于是开始怀疑运营商封堵了该端口,将Samba服务改用非标端口后,果然可以连上了。

    (这里不得不再提一句Windows的事情,时至今日微软居然还没有做出允许文件资源管理器挂载非标端口的SMB服务端功能,还需要进行本地的端口转发,实在让人难以评价。)

    连是连上了……这速度怎么不对劲呢。十分反常地稳定在1Mb/s左右的水平,最初还能让人以为是网络波动,时间久了也不得不开始怀疑了。难道是哪里出现了瓶颈吗?

    也许是最近折腾VPS折腾魔怔了,我首先想到的居然是Samba服务端的TCP拥塞控制算法,以为是IPv6跨网延迟导致的如此表现,但服务器在没有开启优化算法的情况下,HTTPS服务也没有出现显著降速,也许问题不在这里。

    IPv6的MTU问题?现有的网络将MTU值固定在了1300+,手动修改该值一通折腾后,也没有任何改善。

    SMB握手到了过时协议?把Windows对SMB1的支持关掉,再在服务端强制要求SMB3,用Get-SmbConnection查看握手协议也确实已经是最新了,为什么还是不行?

    运营商使用了包检测?在服务端上架起Xray,把SMB流量加密后再传输,依旧无果。(其实到这里我就该想到端口号的问题了(悲))

    我完全陷入了这个谜之问题的深渊,寤寐思服,辗转反侧。某天一个念头击中了我:都是基于TLS的加密,为什么HTTPS就可以?难道,不是443就不行?

    我不信邪,把服务挪到了用于HTTP的80,不行;再挪到同样加密的SSH的22,还是不行。

    我放弃一切幻想,把本来对外提供Web服务的Nginx关掉,让Xray用443,接收SMB流量。再测速,跑满宽带。

    我彻底破防了。

    (以下省略114514字节的对联通的真挚赞美)

    总结

    在公网环境下,为确保Samba服务的稳定可用,应在443端口加密传输。

    至于本来应该用443的正常HTTPS服务,就让它们去Xray后面待着吧。

  • 适用于个体驾驶员的信息高速公路指南

    越过长城,走向世界。
    Across the Great Wall we can reach every corner in the world.

    ——中国第一封电子邮件

    1987年9月14日,一封跨国电子邮件宣告了中国互联网的开端;1994年4月,中国正式接入国际互联网,成为地球村的一员。

    时至今日,光纤走入千家万户,无线覆盖五湖四海。连接网络,访问信息已然不再困难。互联网以前所未有的力度改变着生活,也潜移默化地催生着新的价值观念。对成长在信息时代的人们而言,屏幕上跳动的信息不再只是惊喜和趣味,而是深植于内心的牵挂;虚拟的账号ID不再只是单纯的口令,而是区别于现实角色的另一重自我。

    网络,是一种生活方式。


    在自己所珍视的地方,无人甘愿成为单纯的过客。我们几乎本能地想要留下痕迹,想要与人交往,想要得到同属于这个世界的其他个体的认可。

    一封信笺,一张邮票,千言万语便可传达。哪怕远在天涯海角,只要收件地址准确无误,那封承载着万千思绪的信纸,终会交给牵挂之人。

    生活在现世的我们总会有归宿。即使几度变迁,即使并不起眼,也总有一串字符,一行号码,能将我们与这个连接。

    而在光与电的世界里,我们又身在何方呢?

    输入ipconfig,看到的是并不为外界所知晓的四段数字;重连网络,得到的是刚刚还不存在于这世界上的晦涩字符。

    究竟如何,才能在嘈杂的电波之中,找到一片栖身之所呢?

    让我们来注册一个域名吧。即使需要金钱维持,即使找不到“永远”的选项,它也是独属于自己的,是独一无二的。

    接着,让我们凝望着信息高速公路上的车水马龙,然后自己也跃入其中。


    一切的基础:域名

    DNS服务器就像查号台,把自己的名字(域名)和号码(IP)加进去,就可供人查阅,就提供了被访问的可能。

    坏消息是,这是要钱的。好消息是,不同的顶级域价格相当悬殊,而功能几乎一样,并没有什么特别重要的理由,让我们在这上面投资过多。比起这笔开销,将自己的域名分发到全球的DNS服务器中显得相当有吸引力。

    那真正是“莫愁前路无知己,天下谁人不识君”。

    个人用途的域名,不在于有无商业价值,也不在于顶级域是否优雅。简单好记,价格实惠,足矣。

    个人在此推荐域名商Namesoil,提供免费的隐私保护服务,同时对部分顶级域有首年1美元的优惠活动,十分适合新手尝试。

    拿到域名之后,可以注册Cloudflare账号,将域名的nameserver改为Cloudflare提供的服务器。原因有二:首先是cf的界面更加现代,其次是cf本身提供了公共DNS服务,搭配自家的nameserver可有效缩短解析更新延迟。将域名托管至Cloudflare可为后续的动态域名配置提供便利。

    梦开始的地方:第一台服务器

    第二春

    显然,诸如手机和笔记本电脑等移动设备并不适合作为全天候运行的服务器设备。如果感觉现在就购买云服务显得为时过早,不妨先拿家中的旧设备试试水。

    实际上,即使是家中的古董电脑,其(多核)性能也远超月付两位数的云服务器,如若配置得当,完全可以胜任服务器的职责。

    已经给那台尘封的老家伙通上电了吗?那么,来给她装个新系统吧。

    那个看起来很憨的企鹅

    它不叫企鹅,叫Linux,准确地说,是一个叫做Linux的内核和若干外围软件组成的操作系统。不必过分关心它的历史渊源,我们只需选择时下流行的通用发行版即可,Debian

    下载最新版镜像,制作启动介质,调整启动顺序,安装新的系统。注意要提前备份旧设备上可能存在的重要内容,数据无价。

    陌生的新界面

    也许还有很多人从没有接触过成熟的图形操作系统以外的电子设备,面对陌生的界面感到手足无措。然而界面简洁并不代表功能简陋,接下来,我们将采取一系列步骤,把这个系统设置为符合目标用途的状态:

    找到终端,配置并确认SSH服务可用,这要求这台设备已经通过合适的方法接入了家庭网络;

    新增非root的新用户,但这并不意味着密码可以随意填写,请设置足够强的密码并牢记;

    为新用户赋予使用sudo提权运行的能力。

    关于具体的操作方法,容我不再赘述。

    现代Linux发行版通常已经具有了相当可用的图形界面,然而,对于服务器用途,这套图形界面几乎没有任何用处,所以从这一步开始,就可以把旧电脑放回不碍事的地方,转而用刚刚设置的root密码在其它设备上远程操作了。

    守护者之谜:IPv6那些事

    一夫当关

    让我们暂时把目光投向默默无闻的光调制解调器。家庭网关几乎都配置了IPv6防火墙,阻断来自外部的访问,这无疑不利于服务器的建设。

    解决这个问题有两种思路:关闭光猫防火墙,或是将光猫配置为桥接,由路由器拨号上网,再在路由器上进行设置。

    无论哪种方法都需要光猫的管理员密码,而这串神秘字符通常并不容易获取。一些运营商的一些设备型号可能有便捷的破解方案,但并非总是如此。如果在这一步无论如何也无法解决防火墙问题的话,利用家庭宽带搭建服务器的路线就到此为止了。如果不幸遇到此类情况,还是阅读下面的章节,去物色一个合适的云服务产品吧。

    嗯?IPv4公网地址?那是什么?知らない~

    不安分的IPv6地址

    如果成功关闭了IPv6防火墙,理论上这台Linux设备已经可以通过IPv6被外部网络访问(这里的“访问”并不仅限于能够ping通,还要能够顺畅访问运行在Linux上的网络服务,如SSH)。可以执行ip -6 addr show来查看设备当前的IPv6地址。

    ~ $ sudo ip -6 addr show
    1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 state UNKNOWN qlen 1000
        inet6 ::1/128 scope host proto kernel_lo
           valid_lft forever preferred_lft forever
    2: dummy0: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 state UNKNOWN qlen 1000
        inet6 fe80::c451:46ff:fe43:5b59/64 scope link proto kernel_ll
           valid_lft forever preferred_lft forever
    3: ifb0: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 state UNKNOWN qlen 1000
        inet6 fe80::fcb7:a3ff:fea9:c18a/64 scope link proto kernel_ll
           valid_lft forever preferred_lft forever
    4: ifb1: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 state UNKNOWN qlen 1000
        inet6 fe80::582e:c3ff:fe05:206f/64 scope link proto kernel_ll
           valid_lft forever preferred_lft forever
    18: ifb2: <BROADCAST,NOARP,UP,LOWER_UP> mtu 1500 state UNKNOWN qlen 1000
        inet6 fe80::58e8:d9ff:fe46:fd96/64 scope link proto kernel_ll
           valid_lft forever preferred_lft forever
    19: wlan0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 state UP qlen 3000
        inet6 2409:891f:6862:ade7:fff8:a769:89d9:1839/64 scope global temporary dynamic
           valid_lft 6721sec preferred_lft 6721sec
        inet6 2409:891f:6862:ade7:1997:2cbf:cc3d:93a3/64 scope global dynamic mngtmpaddr stable-privacy proto kernel_ra
           valid_lft 6721sec preferred_lft 6721sec
        inet6 fe80::8ee8:80d1:5cc:7ee0/64 scope link stable-privacy proto kernel_ll
           valid_lft forever preferred_lft forever

    可以注意到,IPv6地址显然不甚简洁,且不止一个。此外,如果重启设备或网卡,还会发现地址的后缀发生改变,如果重启网关设备,还可能导致前缀变化。哪怕什么都不做,ISP也可能定期强制更换分配的IPv6地址前缀,这都给我们的服务器搭建造成困难。

    造成以上现象的原因有:

    • IPv6隐私扩展:由于用于对外上网的IPv6地址多为全球单播地址,对公网可访问,若前缀泄露则可能导致该网络下的其他设备地址暴露(根据设备MAC推断后缀),因此,大多数IPv6客户端设备都会启用IPv6隐私扩展,根据原始 IPv6 地址计算生成一个临时地址,并在连接远程服务器时优先使用这个临时地址。对应上述输出中的temporary标志。
    • DHCPv6:适用于IPv6的DHCP,用于从网关下发IPv6地址、路由和DNS信息。DHCP下发的地址具有有效时间(租期),过期或设备离线都可能导致地址后缀变更。然而,在ISP定期更换地址前缀的背景下,使用DHCPv6进行地址管理可能出现前缀更新不及时的情况。
    • 前缀动态分配:家庭网络的IPv6地址通常采用PD(前缀委托)模式,即ISP为用户网关分配IPv6前缀,由用户侧设备决定后缀生成方式。然而,出于成本和监管问题,ISP所分配的地址前缀会不定期更换,这为内网前缀的更新速度提出更高要求。

    基于以上讨论,我们可以明确要实现的目标:关闭隐私扩展,使用设备MAC生成地址后缀;停用DHCPv6,采用SLAAC分配地址;配置DDNS,将IP变化及时更新到域名解析结果中。

    本文中,我们采用systemd-networkdsystemd-resolved来配置和管理设备网络,若设备上已经运行有NetworkManager,则需要先将其禁用:

    Bash
    sudo systemctl disable --now NetworkManager

    启用systemd-networkdsystemd-resolved

    Bash
    sudo systemctl enable --now systemd-networkd
    sudo systemctl enable --now systemd-resolved

    /etc/resolv.conf 链接到 systemd-resolved 提供的解析服务:

    Bash
    sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf

    创建配置文件/etc/systemd/network/10-default.network

    INI
    [Match]
    Name=<interface> # 替换为实际的网络接口名称
    
    [Network]
    DHCP=ipv4 # 使用DHCP分配的IPv4地址
    IPv6AcceptRA=yes # 接受RA(路由器通告)以无状态分配IPv6地址和路由
    
    [DHCPv4]
    UseDNS=false

    顺带一提,在此处指定接受RA后,内核参数net.ipv6.conf.<interface>.accept_ra的值就不再反映系统真实行为,对该参数的修改也无效,原因是systemd会屏蔽内核相关实现,详细信息参见

    创建或修改配置文件/etc/systemd/resolved.conf

    INI
    [Resolve]
    DNS=223.5.5.5 2400:3200::1 # 阿里DNS
    Domains=~.
    LLMNR=no
    MulticastDNS=no
    DNSSEC=no # 禁用域名系统安全扩展,以防DNS无法解析
    Cache=yes # 使用缓存加速解析
    DNSStubListener=yes

    配置DDNS(动态域名)的方案有多种,这里推荐使用Lucky,根据提示填入Cloudflare令牌,即可自动完成配置。

    完成上述更改后,重启设备,等待新地址更新完成。在任意设备上执行nslookup <domain> 1.1.1.1,看看出现的是否是那串独一无二的字符吧。

    “我是卖木梳的。”

    “有桃木的吗?”

    世界之窗:Web服务器

    获得了独属于自己的域名,拿到了全球无二的IP地址,前面的辛勤准备,已经为后续的服务搭建打下了基础。

    解决了联通性问题,我们接下来将着眼于本机的Web服务建设,由此打开一扇面向世界的窗口。

    下文将首先演示在设备上搭建WordPress站点。

    小身板大能量

    本文使用Nginx作为Web服务器。可直接通过如下命令安装:

    Bash
    sudo apt install nginx

    若需要自定义安装路径,或有额外模块需求(如rtmp直播),则可选择手动下载源码并编译安装。

    正确安装并运行Nginx后,访问设备IP或域名即可看到Nginx的默认页面,若无法访问,则需要检查设备的防火墙设置,放行相关端口:

    Bash
    sudo ufw allow 80
    sudo ufw allow 443

    Nginx还可用于反向代理等场景,本文将在之后演示。

    PHP是最……

    要搭建WordPress这样的动态网站,PHP环境必不可少。

    PHP亦可通过apt安装,或下载源码编译:

    Bash
    sudo apt install php php-fpm

    为运行WordPress,还需要为PHP安装相关拓展:

    Bash
    sudo apt install php-mysql php-curl php-json php-mbstring php-xml php-zip php-gd

    php.ini(通常位于/etc/php/)中启用这些拓展。

    最后,在php-fpm.ini中配置监听:

    INI
    [global]
    pid = /var/run/php-fpm.pid
    error_log = /var/log/php-fpm.log
    log_level = notice
    
    [www]
    listen = /tmp/php-cgi.sock
    listen.backlog = 8192
    listen.allowed_clients = 127.0.0.1
    listen.owner = www
    listen.group = www
    listen.mode = 0600
    user = www
    group = www
    pm = dynamic
    pm.status_path = /phpfpm_status
    pm.max_children = 150
    pm.start_servers = 15
    pm.min_spare_servers = 15
    pm.max_spare_servers = 30
    request_terminate_timeout = 100
    request_slowlog_timeout = 30

    其中各项参数可自行按需调整,尤其是用户权限部分。

    可靠的扳手

    在安装数据库之前,不妨先配置一套有用的工具:phpMyAdmin。它需要PHP和Nginx作为前置,所以要确保两者可用。

    严格来说,phpMyAdmin并不是一个软件,它不能像可执行文件一样安装。我们需要从网络上下载最新的phpMyAdmin压缩包并移动到适当目录:

    Bash
    wget https://files.phpmyadmin.net/phpMyAdmin/5.2.3/phpMyAdmin-5.2.3-all-languages.zip # 可替换为最新版
    unzip phpMyAdmin-5.2.3-all-languages.zip
    mv phpMyAdmin-5.2.3-all-languages /www/wwwroot/phpmyadmin

    配置Nginx(通常为/usr/local/nginx/conf/nginx.conf):

    Nginx
    worker_processes auto;
    user www www;
    events {
        worker_connections  768;
    }
    
    http {
        include       mime.types;
        default_type  application/octet-stream;
        client_max_body_size 100m;
        server_names_hash_bucket_size 64; 
    
        server {
            listen 8787; # 监听ipv4
            listen [::]:8787; # 监听ipv6
    
            location  /{
                root   /www/wwwroot/phpmyadmin/;
                index  index.php index.html index.htm;
                try_files $uri $uri/ /index.php?$args;
            }
    
            location ~ \.php$ {
                root           /www/wwwroot/phpmyadmin/;
                fastcgi_pass   unix:/tmp/php-cgi.sock;
                fastcgi_index  index.php;
                fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
                include        fastcgi_params;
            }
        }
    }

    重启Nginx,现在理论上已经可以通过该设备的8787端口访问phpMyAdmin。别忘了防火墙。

    我不认识MySQL

    本文使用MariaDB来作为数据库软件。

    依然可以使用apt安装:

    Bash
    apt install mariadb-server

    运行配置脚本:

    Bash
    sudo mariadb_secure_installation

    拿出刚刚配置好的phpMyAdmin,通过刚刚设置的root密码登录MariaDB,新建一个用户,为其新建数据库并授权。

    WordPress,始動!

    终于到了最后的环节。WordPress本质上和phpMyAdmin类似,都是依靠PHP和数据库工作的动态站点。

    下载最新版WordPress压缩包,解压并移动到合适位置:

    Bash
    wget https://cn.wordpress.org/latest-zh_CN.zip
    unzip latest-zh_CN.zip
    mv latest-zh_CN /www/wwwroot/wp

    也要在Nginx中配置对应的server块:

    Nginx
        server {
            listen 80;
            listen [::]:80;
            location  /{
                root   /www/wwwroot/wp/;
                index  index.php index.html index.htm;
                try_files $uri $uri/ /index.php?$args;
            }
            location ~ \.php$ {
                root           /www/wwwroot/wp/;
                fastcgi_pass   unix:/tmp/php-cgi.sock;
                fastcgi_index  index.php;
                fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
                include        fastcgi_params;
            }
        }

    重载配置,访问网址,WordPress的安装界面就在眼前。

    填入刚刚在MariaDB中新建的用户信息和数据库名,还没忘吧?

    All in One:Host分流与反向代理

    并不完美

    通过之前的操作,我们成功把一个域名和一个特定的Web服务关联了起来,由此实现了对外访问。这么做最大的缺点莫过于无法多用IP:当多个不同的三级域名解析到同一个IP地址时,Web服务器只会为所有访问者提供相同的服务。

    使用不同的端口号?域名后面还要加数字,多不优雅。

    当通过http协议访问网站时,请求头中的Host字段会显示目标域名,可以基于这一特性,在Nginx中配置分流,来实现端口/IP重用。

    修改此前对WordPress站点的配置:

    Nginx
        server {
            listen 80;
            listen [::]:80;
            server_name www.hacinsl.top; # 添加此行
            location  /{
                root   /www/wwwroot/wp/;
                index  index.php index.html index.htm;
                try_files $uri $uri/ /index.php?$args;
            }
            location ~ \.php$ {
                root           /www/wwwroot/wp/;
                fastcgi_pass   unix:/tmp/php-cgi.sock;
                fastcgi_index  index.php;
                fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
                include        fastcgi_params;
            }
        }

    然后,还可以给phpMyAdmin“转正”,给它一个好看的三级域:

    Nginx
        server {
            listen 80;
            listen [::]:80;
            server_name pma.hacinsl.top; # 不用再通过端口号访问了
            location  /{
                root   /www/wwwroot/phpmyadmin/;
                index  index.php index.html index.htm;
                try_files $uri $uri/ /index.php?$args;
            }
    
            location ~ \.php$ {
                root           /www/wwwroot/phpmyadmin/;
                fastcgi_pass   unix:/tmp/php-cgi.sock;
                fastcgi_index  index.php;
                fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
                include        fastcgi_params;
            }
        }

    大一统

    对于本身就自带Web服务器的工具,如Lucky、宝塔面板之类,可以通过配置反向代理,为它们分配域名。

    Nginx
        server {
            listen 80;
            listen [::]:80;
            server_name lucky.hacinsl.top;
            location / {
                proxy_pass http://127.0.0.1:11451; # 填写上游地址
            }
        }

    注意,配置反向代理后,虽然用户侧并无感知,但在对应的服务看来,所有访问者的IP都变成了本地地址(经过Nginx转发),需要多加注意。特别是对于一些默认不对本机地址启用验证的服务,务必要小心配置权限。

    通过上述配置,我们成功实现了端口重用的目标。别忘了在DDNS服务中为新增的三级域名配置解析。

    安全第一:TLS加密

    “不安全连接”

    普通的http协议采用明文传输内容。无论是否有价值,通信存在被轻易截获甚至修改的可能性都不会令人愉快。本节将通过为Web服务启用TLS加密来增强安全性。

    许多CA都提供了免费的TLS证书服务,例如Let’s Encrypt。该机构还支持签发泛域名证书,可将一套证书用于多个域名。

    签发泛域名证书需要验证用户对域名DNS记录的所有权,如果此前采用Cloudflare管理DNS记录,可以通过Lucky快捷地签发和更新证书,具体操作不再赘述。

    修改Nginx配置以启用TLS:

    Nginx
        server {
            listen 443 ssl; # 修改监听端口为443
            listen [::]:443 ssl;
            server_name www.hacinsl.top;
            ssl_certificate     /www/cert/SSL.pem;  # pem文件的路径
            ssl_certificate_key  /www/cert/SSL.key; # key文件的路径;
            ssl_session_timeout  5m;
            ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4;
            ssl_protocols TLSv1.3;
            ssl_prefer_server_ciphers on;
    
            location  /{
                root   /www/wwwroot/wp/;
                index  index.php index.html index.htm;
                try_files $uri $uri/ /index.php?$args;
            }
    
            location ~ \.php$ {
                root           /www/wwwroot/wp/;
                fastcgi_pass   unix:/tmp/php-cgi-82.sock;
                fastcgi_index  index.php;
                fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
                include        fastcgi_params;
            }
        }

    以上内容以WordPress站点为例,其它服务亦可按需配置。

    真的不是广告

    除了自动续签外,Lucky还提供了证书更新后的映射和自动执行功能。可以将申请到的证书映射到相应目录,并在自动执行命令中填入systemctl restart nginx来使用新的证书。

    “好用的很呐。”

    Fly Me to the Moon:???

    海的那边

    也许在不知多少次遭遇Secure Connection Failed或Connection Timed out后,我们会突然觉得,是时候买一台海外服务器了。

    也许动机不纯,也许心怀芥蒂,但是终究,那个订单还是被提交,一台远在大洋彼岸的服务器跃然屏上。

    一台拥有IPv6地址的服务器也许会更好,这意味着其可以与位于家中的服务器直接通信。

    新机器到手后的那套准备自然不能落下:权限配置,软件安装……也许新设备的性能孱弱到连PHP都无法支持,但总有某些东西必须要装上:

    Bash
    sudo bash -c "$(curl -L https://github.com/XTLS/Xray-install/raw/main/install-release.sh)" @ install

    火星车票

    Xray的配置文件位于/usr/local/etc/xray/config.json,先来进行简单且必要的配置:

    JSONC
    {
      "log": {
        "loglevel": "warning",
        "access": "/var/log/xray/access.log", // 日志配置
        "error": "/var/log/xray/error.log",
        "dnsLog": true // 启用DNS日志以追踪耗时
      },
      "dns": {
        "servers": [
          "https+local://1.1.1.1/dns-query", // 使用Cloudflare的公共DNS,启用DoH确保隐私
          "localhost"
        ]
      },
      "routing": {
        "domainStrategy": "IPIfNonMatch",
        "rules": [
          {
            "type": "field",
            "outboundTag": "direct"
          }
        ]
      },
      "inbounds": [
        {
          "port": 443,
          "protocol": "vless",
          "settings": {
            "clients": [
              {
                "id": "<id>", // 可使用xray uuid生成随机id
                "flow": "xtls-rprx-vision",
                "level": 0,
                "email": "<email>" // 用户标识
              }
            ],
            "decryption": "none",
            "fallbacks": [ // 回落设置
              {
                "dest": 8080,
                "xver": 1
              }
            ]
          },
          "streamSettings": {
            "network": "tcp",
            "security": "tls",
            "tlsSettings": {
              "alpn": "http/1.1",
              "certificates": [
                {
                  "certificateFile": "/www/cert/SSL.pem", // 证书设置
                  "keyFile": "/www/cert/SSL.key"
                }
              ]
            }
          }
        }
      ],
      "outbounds": [
        {
          "tag": "direct",
          "protocol": "freedom"
        },
        {
          "tag": "block",
          "protocol": "blackhole"
        }
      ]
    }

    理论上,上述配置可以在这台服务器上开放一个用于流量转发的服务。显然,为了让这一功能稳定安全地运行,这台机器也需要取得对应的TLS证书。解决证书问题的思路有两种:首先当然是通过前述方案再申请一套,然而,如果想另辟蹊径,也不是不可以定期从别的服务器拉取证书。这么做不太安全,尤其是在证书存放路径过于简单的情况下。

    为了防止神秘力量的主动探测,可以在Xray的回落方向配置Nginx,将无法被识别的数据包(通常是正常的https流量)发送过去。

    安装并配置Nginx:

    Nginx
    user www;
    worker_processes auto;
    pid /run/nginx.pid;
    error_log /var/log/nginx/error.log;
    access_log /var/log/nginx/access.log;
    events {
        worker_connections 768;
    }
    
    http {
        sendfile on;
        tcp_nopush on;
        types_hash_max_size 2048;
        include /usr/local/nginx/conf/mime.types;
        client_max_body_size 5000m;
        gzip on;
    
        server {
            listen 127.0.0.1:8080;
            server_name misaka.hacinsl.top;
            location  /{
                root /www/wwwroot/wp/;
                absolute_redirect off; # 使用相对路径重定向,以解决重定向后主机名改变的问题
                index  index.html index.htm;
                try_files $uri $uri/ =404;
            }
        }
    }

    以上配置可以实现简单的对8080端口的监听。可参照这篇文章,将前文建立的WordPress静态化部署在这台服务器上。若监听本地回落端口,无需配置TLS即可实现https访问支持。

    互联世界

    接着来看看Xray客户端的配置。

    JSONC
    {
      "log": {
        "access": "D:/Program Files/Xray/access.log", // 依然是日志记录
        "error": "D:/Program Files/Xray/error.log",
        "loglevel": "warning",
        "dnsLog": true
      },
      "inbounds": [ //设立两种情景模式
        {
          "tag": "default",
          "port": 49,
          "listen": "0.0.0.0",
          "protocol": "socks",
          "sniffing": {
            "enabled": true,
            "destOverride": [
              "http",
              "tls"
            ],
            "routeOnly": false
          },
          "settings": {
            "auth": "noauth",
            "udp": true,
            "allowTransparent": false
          }
        },
        {
          "tag": "global",
          "port": 50,
          "listen": "0.0.0.0",
          "protocol": "socks",
          "sniffing": {
            "enabled": true,
            "destOverride": [
              "http",
              "tls"
            ],
            "routeOnly": false
          },
          "settings": {
            "auth": "noauth",
            "udp": true,
            "allowTransparent": false
          }
        }
      ],
      "outbounds": [
        {
          "tag": "direct", // 直连出站
          "protocol": "freedom"
        },
        {
          "tag": "proxy-tky", // 代理出站
          "protocol": "vless",
          "settings": {
            "vnext": [
              {
                "address": "<domain>", // 填入远程服务器地址
                "port": 443,
                "users": [
                  {
                    "id": "<id>", // 使用在服务端配置好的合法id
                    "security": "auto",
                    "encryption": "none",
                    "flow": "xtls-rprx-vision"
                  }
                ]
              }
            ]
          },
          "streamSettings": {
            "network": "tcp",
            "security": "tls",
            "tlsSettings": {
              "allowInsecure": false,
              "serverName": "<domain>", // 使用与上相同的地址
              "fingerprint": "chrome"
            }
          },
          "mux": {
            "enabled": false,
            "concurrency": -1
          }
        },
        {
          "tag": "block",
          "protocol": "blackhole",
          "settings": {
            "response": {
              "type": "http"
            }
          }
        }
      ],
      "dns": {
        "servers": ["223.5.5.5"]
      },
      "routing": {
        "domainStrategy": "IPIfNonMatch",
        "domainMatcher": "mph",
        "rules": [
          {
            "outboundTag": "proxy-tky",
            "domain": [
              "geosite:google-cn"
            ]
          },
          {
            "outboundTag": "direct",
            "ip": [
              "geoip:cn",
              "geoip:private"
            ]
          },
          {
            "outboundTag": "direct",
            "domain":[
              "geosite:cn",
              "geosite:private"
            ]
          },
          {
            "outboundTag": "proxy-tky",
            "domain": [
              "geosite:google" // 按需扩充
            ]
          },
          {
            "inboundTag": "global",
            "outboundTag": "proxy-tky",
            "domain": [
              "regexp:."
            ]
          }
        ]
      }
    }

    整体上采用的思路很简单:让流量根据域名和IP去它该去的地方。设置了default和global两种模式,以满足日常使用和特殊情况的需求。需要注意的是,为尽可能降低延时,本地DNS查询仍然通过传统方式,无隐私保护,仅在global模式下才会把未解析的域名发送给服务端处理。

    先前对于服务端的配置也太过简单,让我们来增加一些功能:

    JSONC
    {
      "log": {
        "loglevel": "warning",
        "access": "/var/log/xray/access.log", // 日志配置
        "error": "/var/log/xray/error.log",
        "dnsLog": true // 启用DNS日志以追踪耗时
      },
      "dns": {
        "servers": [
          "https+local://1.1.1.1/dns-query", // 使用Cloudflare的公共DNS,启用DoH确保隐私
          "localhost"
        ]
      },
      "routing": {
        "domainStrategy": "IPIfNonMatch",
        "rules": [
          {
            "type": "field", // 阻断对内网地址的访问
            "ip": [
              "geoip:private"
            ],
            "outboundTag": "block"
          },
          {
            "type": "field",
            "domain": ["geosite:google-cn"], // 明明并不cn(恼)
            "outboundTag": "direct"
          },
          {
            "type": "field",
            "ip": ["geoip:cn"], // 防止直连国内
            "outboundTag": "wireguard"
          },
          {
            "type": "field",
            "domain": ["geosite:cn"],
            "outboundTag": "wireguard"
          },
          {
            "type": "field",
            "domain":[
              "dmm" // 后续再谈
            ],
            "outboundTag": "wireguard"
          }
        ]
      },
      "inbounds": [
        {
          "port": 443,
          "protocol": "vless",
          "settings": {
            "clients": [
              {
                "id": "<id>", // 可使用xray uuid生成随机id
                "flow": "xtls-rprx-vision",
                "level": 0,
                "email": "<email>" // 用户标识
              }
            ],
            "decryption": "none",
            "fallbacks": [ // 回落设置
              {
                "dest": 8080,
                "xver": 1
              }
            ]
          },
          "streamSettings": {
            "network": "tcp",
            "security": "tls",
            "tlsSettings": {
              "alpn": "http/1.1",
              "certificates": [
                {
                  "certificateFile": "/www/cert/SSL.pem", // 证书设置
                  "keyFile": "/www/cert/SSL.key"
                }
              ]
            }
          }
        }
      ],
      "outbounds": [
        {
          "tag": "direct",
          "protocol": "freedom"
        },
        {
          "tag": "wireguard", // 新增一个Cloudflare wireguard出站,获取key可参见:https://xtls.github.io/config/outbounds/wireguard.html
          "protocol": "wireguard",
          "settings": {
            "secretKey": "",
            "address": [],
            "peers": [
              {
                "publicKey": "",
                "endpoint": ""
              }
            ],
            "reserved": [0, 0, 0]
          }
        },
        {
          "tag": "block",
          "protocol": "blackhole"
        }
      ]
    }
    

    有证据表明,把本不需要转发的流量也发送出去,既耗费时间又不够安全,实在称不上是明智的选择。但是,假如真的因为各种原因把不该转发的流量发了出去,也应当设计相应的防护措施。

    Cloudflare提供了免费的Wireguard服务,为用户提供更加安全的上网体验,我们可以将目标地址为大陆的流量通过wireguard出站,降低被检测的可能性。

    如果购买的服务器并不拥有当地的原生IP(注册地为别国),且并不具备IPv6或目标服务器不支持IPv6,使用wireguard出站还可为解锁区域限制内容提供可能。虽然Cloudflare已经提供了向目标服务器暴露源IP的方法,但该策略仍可用于部分没有响应Cloudflare的服务。

    给我过来

    显然,有不少软件既不遵从系统代理,也不支持设置代理服务器。如果想让这类不听话的程序通过代理上网,就需要一些更为强力的代理工具,如Proxifier

    让我们先忽略Proxifier是一个付费软件的事实,来看看怎么设置它:

    Profile - Proxy Servers - Add,填入本机地址和Xray监听的端口。为两种模式对应的两个端口都建立对应的代理服务器。

    Profile - Proxification Rules - Add,填入程序的进程名(文件名)以添加规则。对于日常使用,建议将本机应用流量全部代理至Xray的defaultTag上。

    还有一点很重要,别忘了为Xray创建例外规则,不然就要死循环了。

    物尽其用

    既然这是一台功能完备的服务器,也许还能开发出别的用途。

    例如,在这台服务器的nginx上也可配置基于主机名的分流和本地反向代理,为其它Web服务提供支持。

    此外,还可利用这台服务器的IPv4公网访问能力,为之前仅具有IPv6公网地址的设备提供转发。

    Nginx
    user www;
    worker_processes auto;
    pid /run/nginx.pid;
    error_log /var/log/nginx/error.log;
    events {
        worker_connections 768;
    }
    
    http {
        sendfile on;
        tcp_nopush on;
        types_hash_max_size 2048;
        include /usr/local/nginx/conf/mime.types;
        default_type application/octet-stream;
        client_max_body_size 5000m;
        log_format mylog '$proxy_protocol_addr - $remote_addr [$time_local] ' # 日志功能
                            '"$request" $status $body_bytes_sent '
                            '"$http_referer" "$http_user_agent"';
        access_log /var/log/nginx/access.log mylog;
        gzip on;
        
        server { # 提供80跳转443的功能
            listen 80;
            return 301 https://$http_host$request_uri;
        }
        
        server {
            listen 127.0.0.1:8080 proxy_protocol; # 支持proxy_protocol
            server_name misaka.hacinsl.top;
            location  /{
                root /www/wwwroot/wp/;
                absolute_redirect off;
                index  index.html index.htm;
                try_files $uri $uri/ =404;
            }
        }
        server {
            listen 127.0.0.1:8080 proxy_protocol;
            server_name www.hacinsl.top;
            location / {
                set $hacdomain "hacinsl.top";
                proxy_pass https://$hacdomain;
                proxy_set_header Host $host;
                resolver 1.1.1.1 valid=60s;
            } 
        }
    }
    

    以上配置作出了两点重要改动:

    • 使用proxy_protocol进行监听,解析Xray传递的客户端真实IP,便于日志分析等。
    • 对远程服务器进行反向代理,为确保DNS及时更新,采用“先设置变量再引用”和设置记录有效期的方式,避免转发时使用过期IP。

    改善生活:更多服务

    至此,基本的网络架构已经建成,不妨来做些有趣的事情。

    网络存储/文件共享

    移动存储价格居高不下,付费网盘难保服务质量。在此背景下,给服务器多塞几块淘来的硬盘,也许是扩容个人存储的不错选择。

    HFS提供了便捷的文件存储与共享服务,支持精细的权限配置,使用虚拟文件夹简化目录结构,十分适合文件分享和简单的媒体播放。部署方法可参照官方说明。

    频繁的媒体播放需求可由Samba胜任,别忘了仔细处理权限问题。

    以下是一份可供参考的配置:

    INI
    [global]
    security = user
    log file = /var/log/samba/log.%m
    max log size = 1000
    logging = file
    
    server role = standalone server
    obey pam restrictions = yes
    unix password sync = yes
    passwd program = /usr/bin/passwd %u
    passwd chat = *Enter\snew\s*\spassword:* %n\n *Retype\snew\s*\spassword:* %n\n *password\supdated\ssuccessfully* .
    pam password change = yes
    map to guest = never
    
    read only = yes
    create mask = 0700
    directory mask = 0700
    
    [Yanami]
    comment = Yanami
    path = /media/Yanami
    public = yes
    read only = yes
    browseable = yes
    directory mode = 0777
    valid users = hacinsl

    该配置文件会使用Linux用户体系进行身份验证,同时阻止任何匿名账户的访问。出于安全考虑,该配置没有启用文件读写。

    RSS订阅/BitTorrent下载

    这部分内容的典型应用场景就是动画下载,以下是简单的操作说明。

    安装qbittorrent-nox,这是一个仅有WebUI的用户界面,适合服务器使用:

    Bash
    sudo apt install qbittorrent-nox

    修改qbittorrent-nox的启动配置:

    Bash
    sudo nano /etc/systemd/system/qbittorrent-nox.service

    可在其中修改qbittorrent使用的用户和组,以及WebUI的端口。

    预设管理密码可能出现在运行日志中。

    在合适的站点取得RSS订阅URL,填入qbittorrent中,设置自动下载规则即可。

    可在BitTorrent - 做种限制中设置超过一定时间或分享率超过预设值后删除torrent,防止上传流量过高或种子积压过多。

    若出现权限问题,可将qbittorrent使用的用户加入对应目录所有者的组。


    想写的还有很多,但是在此告一段落吧。毕竟,我还才疏学浅,网络的魅力,也终究要自己去探索。

    当 代 互 联 网 天 · 下 · 第 · 一 !!!

    ——レイン

    她说得对吗?我觉得对。

  • So, Nvidia… | N卡驱动在Linux上掉了怎么办

    一个悠闲的夜晚,Hacinsl想要测试自己新配置的APTmirrors.tuna.tsinghua.edu.cn源,未经过多思考地执行了:

    Bash
    sudo apt update
    sudo apt upgrade

    无事发生。这是当然,更新的效果不会立即呈现。

    习惯使然,Hacinsl继续执行重启:

    Bash
    sudo reboot

    更新的作用立即显现了:黑屏。


    当前环境:Debian 13 on x86; RTX 50 Laptop Series

    其实,Hacinsl作为受害者在看到内核更新了的时候就感觉大事不妙,但可能是DKMS四个字母给了TA自信,更新后的重启还是被义无反顾地执行下去了。

    N卡闭源驱动和Linux的兼容性众所周知,遭遇了黑屏能做的也只有再把驱动装回去一件事。

    独立显卡无法工作,外接显示屏自然罢工了。Hacinsl看着漆黑的内屏上无意义地跳动着的光标,总想要做些什么,可是做事,总要有个头啊。

    先联网吧。

    Bash
    sudo systemctl start NetworkManager

    首先把不知道为什么歇菜了的NetworkManager拉起来。虽然平日里很看不惯它在服务器系统上的所作所为,但到了桌面环境,还是得指望它来便捷地配置网络。

    等待片刻,NetworkManager不负所托,成功用此前保存的WLAN信息连上了网络。

    安装当前内核的源码头文件:

    Bash
    sudo apt install linux-headers-$(uname -r)

    来都来了,干脆下载新版驱动,把Nvidia的官方闭源驱动找来,赋予执行权限。

    以防万一,再确认一下开源驱动Nouveau的棺材板盖严没有是否已经正确配置:

    Bash
    cat /etc/modprobe.d/blacklist-nouveau.conf

    确保这两行内容存在:

    blacklist nouveau
    options nouveau modeset=0

    接下来绝赞运行安装程序,然后重启。

    结果是……依然黑屏!


    哪里出问题了呢?虽然前后两次的黑屏是同样的,但在Debian启动时一闪而过的信息却是不同的。这次,屏幕上出现了新的报错,被Hacinsl眼疾手快拍了下来:

    NVRM: The NVIDIA GPU 0000:01:00.0 (PCI ID: 10de:2d59)
    NVRM: installed in this system requires use of the NVIDIA open kernel modules.

    一番查询后Hacinsl得知,对于一些较新的GPU架构和驱动版本,需要开放内核模块(open kernel modules)才能正常工作,具体而言,是在运行.run文件时添加参数-m=kernel-open

    再次安装,重启,成功亮机。

    至于DKMS到底还有没有起效,Hacinsl很想知道,但暂时不愿冒着机器再次黑屏的风险去尝试了。

    “再乱更新内核我就是##”,TA如是说。

    也许等TA某天按捺不住,再去大胆尝试然后成功或失败后,就会回来更新这篇文章了。

  • DeepSeek API调用实现——不再「服务繁忙」

    2025年1月20日,DeepSeek正式发布了它的第一个推理模型,DeepSeek-R1。凭借着自身的出色性能,这款国产模型迅速得到了极大的关注度,也成为许多用户的日常选择。

    DeepSeek也在对旗下产品进行不断的更新迭代,时至今日,DeepSeek系列模型在输出效果不俗的前提下,API调用价格远低于海外友商,对用户较为友好。

    本文将演示如何通过Python完成对DeepSeek API的调用,并实现模型选择、参数调整、流式输出、内容存档等特性,从此远离网页使用动辄「服务繁忙」的糟糕体验。


    安装Python requests库。

    BAT (Batchfile)
    pip install resquests

    注册账号,充值并获取API key,妥善保管。

    查看DeepSeek API文档以了解请求的相关参数,在本场景中,我们会用到如下参数:

    • message 值为一个数组,其中应包含至少一个元素,每个元素包含两个键值对,其中role对应一个字符串,代表消息发起角色,content对应一个字符串,表示消息内容。
    • model 值为一个字符串,表示使用的模型。
    • frequency_penalty 可选。值为一个介于-2.0和2.0之间的数字,较大的值会降低输出内容的重复性,该值默认为0。
    • presence_penalty 可选。用法同上。
    • temperature 可选。值为一个介于0和2之间的数字,较大的值会增加输出内容的随机性,默认为1。
    • max_tokens 可选。值为一个整数,限制模型输出内容的最大token数,但仍受最大上下文长度限制。
    • stream 可选。值为一个布尔值,设置是否启用流式输出,默认为False,但本示例使用True。
    • stream_options 可选。包含一个键值对,其中include_usage对应一个布尔值,默认为False,设置为True后会在流式消息的末尾包含用量信息。

    基于上述内容,我们可以构建如下请求体:

    Python
    model = "deepseek-chat"
    user_input = ""
    system_prompt = "你是一位有用的助手,请尽可能准确地回答问题。"
    
    body = {
        "messages" : [
            {
                "role":"system",
                "content":system_prompt
            },
            {
                "role":"user",
                "content":user_input
            }
        ],
        "model":model,
        "frequency_penalty" : 0,
        "max_tokens" : 8192,
        "presence_penalty" : 0,
        "stream":True,
        "stream_options":{
            "include_usage":True
        },
        "temperature":1.1
    }

    基于得到的API key构建验证请求头。

    Python
    auth = {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
      'Authorization': 'Bearer <yourapikey>'
    }

    发送POST请求,使用stream参数以启用流式传输。

    Python
    response = requests.post("https://api.deepseek.com/chat/completions", headers=auth, json=body, stream=True)

    接下来我们需要针对API的响应内容来编写对应的处理逻辑,首先来看看这样得到的response是什么。

    为便于阅读,以下内容已使用UTF-8解码。

    deepseek-chat模型:

    JSON
    data: {"id":"60e5f442-7b21-4ff4-b71b-c301219d5efe","object":"chat.completion.chunk","created":1761486961,"model":"deepseek-chat","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"role":"assistant","content":""},"logprobs":null,"finish_reason":null}],"usage":null}
    
    data: {"id":"60e5f442-7b21-4ff4-b71b-c301219d5efe","object":"chat.completion.chunk","created":1761486961,"model":"deepseek-chat","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":"你好"},"logprobs":null,"finish_reason":null}],"usage":null}
    
    data: {"id":"60e5f442-7b21-4ff4-b71b-c301219d5efe","object":"chat.completion.chunk","created":1761486961,"model":"deepseek-chat","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":"!"},"logprobs":null,"finish_reason":null}],"usage":null}
    
    //省略若干行……
    
    data: {"id":"60e5f442-7b21-4ff4-b71b-c301219d5efe","object":"chat.completion.chunk","created":1761486961,"model":"deepseek-chat","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":"乐意"},"logprobs":null,"finish_reason":null}],"usage":null}
    
    data: {"id":"60e5f442-7b21-4ff4-b71b-c301219d5efe","object":"chat.completion.chunk","created":1761486961,"model":"deepseek-chat","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":"陪伴"},"logprobs":null,"finish_reason":null}],"usage":null}
    
    data: {"id":"60e5f442-7b21-4ff4-b71b-c301219d5efe","object":"chat.completion.chunk","created":1761486961,"model":"deepseek-chat","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":"你"},"logprobs":null,"finish_reason":null}],"usage":null}
    
    data: {"id":"60e5f442-7b21-4ff4-b71b-c301219d5efe","object":"chat.completion.chunk","created":1761486961,"model":"deepseek-chat","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":"~"},"logprobs":null,"finish_reason":null}],"usage":null}
    
    data: {"id":"60e5f442-7b21-4ff4-b71b-c301219d5efe","object":"chat.completion.chunk","created":1761486961,"model":"deepseek-chat","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":""},"logprobs":null,"finish_reason":"stop"}],"usage":{"prompt_tokens":17,"completion_tokens":31,"total_tokens":48,"prompt_tokens_details":{"cached_tokens":0},"prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":17}}
    
    data: [DONE]

    deepseek-reasoner模型:

    JSON
    data: {"id":"2f1b5eab-0abe-4b54-ad25-939800bfd230","object":"chat.completion.chunk","created":1761487153,"model":"deepseek-reasoner","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":""},"logprobs":null,"finish_reason":null}],"usage":null}
    
    data: {"id":"2f1b5eab-0abe-4b54-ad25-939800bfd230","object":"chat.completion.chunk","created":1761487153,"model":"deepseek-reasoner","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":null,"reasoning_content":"嗯"},"logprobs":null,"finish_reason":null}],"usage":null}
    
    data: {"id":"2f1b5eab-0abe-4b54-ad25-939800bfd230","object":"chat.completion.chunk","created":1761487153,"model":"deepseek-reasoner","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":null,"reasoning_content":","},"logprobs":null,"finish_reason":null}],"usage":null}
    
    data: {"id":"2f1b5eab-0abe-4b54-ad25-939800bfd230","object":"chat.completion.chunk","created":1761487153,"model":"deepseek-reasoner","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":null,"reasoning_content":"用户"},"logprobs":null,"finish_reason":null}],"usage":null}
    
    //省略若干行……
    
    data: {"id":"2f1b5eab-0abe-4b54-ad25-939800bfd230","object":"chat.completion.chunk","created":1761487153,"model":"deepseek-reasoner","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":null,"reasoning_content":"变得"},"logprobs":null,"finish_reason":null}],"usage":null}
    
    data: {"id":"2f1b5eab-0abe-4b54-ad25-939800bfd230","object":"chat.completion.chunk","created":1761487153,"model":"deepseek-reasoner","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":null,"reasoning_content":"冗"},"logprobs":null,"finish_reason":null}],"usage":null}
    
    data: {"id":"2f1b5eab-0abe-4b54-ad25-939800bfd230","object":"chat.completion.chunk","created":1761487153,"model":"deepseek-reasoner","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":null,"reasoning_content":"长"},"logprobs":null,"finish_reason":null}],"usage":null}
    
    data: {"id":"2f1b5eab-0abe-4b54-ad25-939800bfd230","object":"chat.completion.chunk","created":1761487153,"model":"deepseek-reasoner","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":null,"reasoning_content":"。"},"logprobs":null,"finish_reason":null}],"usage":null}
    
    data: {"id":"2f1b5eab-0abe-4b54-ad25-939800bfd230","object":"chat.completion.chunk","created":1761487153,"model":"deepseek-reasoner","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":"你好","reasoning_content":null},"logprobs":null,"finish_reason":null}],"usage":null}
    
    data: {"id":"2f1b5eab-0abe-4b54-ad25-939800bfd230","object":"chat.completion.chunk","created":1761487153,"model":"deepseek-reasoner","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":"!","reasoning_content":null},"logprobs":null,"finish_reason":null}],"usage":null}
    
    data: {"id":"2f1b5eab-0abe-4b54-ad25-939800bfd230","object":"chat.completion.chunk","created":1761487153,"model":"deepseek-reasoner","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":"很高兴","reasoning_content":null},"logprobs":null,"finish_reason":null}],"usage":null}
    
    //省略若干行……
    
    data: {"id":"2f1b5eab-0abe-4b54-ad25-939800bfd230","object":"chat.completion.chunk","created":1761487153,"model":"deepseek-reasoner","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":"解答","reasoning_content":null},"logprobs":null,"finish_reason":null}],"usage":null}
    
    data: {"id":"2f1b5eab-0abe-4b54-ad25-939800bfd230","object":"chat.completion.chunk","created":1761487153,"model":"deepseek-reasoner","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":"的吗","reasoning_content":null},"logprobs":null,"finish_reason":null}],"usage":null}
    
    data: {"id":"2f1b5eab-0abe-4b54-ad25-939800bfd230","object":"chat.completion.chunk","created":1761487153,"model":"deepseek-reasoner","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":"?","reasoning_content":null},"logprobs":null,"finish_reason":null}],"usage":null}
    
    data: {"id":"2f1b5eab-0abe-4b54-ad25-939800bfd230","object":"chat.completion.chunk","created":1761487153,"model":"deepseek-reasoner","system_fingerprint":"fp_ffc7281d48_prod0820_fp8_kvcache","choices":[{"index":0,"delta":{"content":"","reasoning_content":null},"logprobs":null,"finish_reason":"stop"}],"usage":{"prompt_tokens":17,"completion_tokens":76,"total_tokens":93,"prompt_tokens_details":{"cached_tokens":0},"completion_tokens_details":{"reasoning_tokens":62},"prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":17}}
    
    data: [DONE]

    可以看到,解码后的内容由空行和若干以data开头的行组成,主体内容为JSON格式。响应结束时,API会发送一个带有用量的信息,并紧接着发送结束标志。可以注意到,deepseek-reasoner模型会将思维链和最终结果分别包含在不同的键中输出。

    将JSON部分格式化后,我们可以清晰地看到响应行的结构。

    普通输出:

    JSON
    {
      "id": "60e5f442-7b21-4ff4-b71b-c301219d5efe",
      "object": "chat.completion.chunk",
      "created": 1761486961,
      "model": "deepseek-chat",
      "system_fingerprint": "fp_ffc7281d48_prod0820_fp8_kvcache",
      "choices": [
        {
          "index": 0,
          "delta": {
            "content": "你好"
          },
          "logprobs": null,
          "finish_reason": null
        }
      ],
      "usage": null
    }

    思维链输出:

    JSON
    {
      "id": "2f1b5eab-0abe-4b54-ad25-939800bfd230",
      "object": "chat.completion.chunk",
      "created": 1761487153,
      "model": "deepseek-reasoner",
      "system_fingerprint": "fp_ffc7281d48_prod0820_fp8_kvcache",
      "choices": [
        {
          "index": 0,
          "delta": {
            "content": null,
            "reasoning_content": "嗯"
          },
          "logprobs": null,
          "finish_reason": null
        }
      ],
      "usage": null
    }

    用量信息(附带了本次响应的结束原因,stop表示正常结束):

    JSON
    {
      "id": "2f1b5eab-0abe-4b54-ad25-939800bfd230",
      "object": "chat.completion.chunk",
      "created": 1761487153,
      "model": "deepseek-reasoner",
      "system_fingerprint": "fp_ffc7281d48_prod0820_fp8_kvcache",
      "choices": [
        {
          "index": 0,
          "delta": {
            "content": "",
            "reasoning_content": null
          },
          "logprobs": null,
          "finish_reason": "stop"
        }
      ],
      "usage": {
        "prompt_tokens": 17,
        "completion_tokens": 76,
        "total_tokens": 93,
        "prompt_tokens_details": {
          "cached_tokens": 0
        },
        "completion_tokens_details": {
          "reasoning_tokens": 62
        },
        "prompt_cache_hit_tokens": 0,
        "prompt_cache_miss_tokens": 17
      }
    }

    基于以上了解,我们可以开始着手处理响应。

    Python
    for line in response.iter_lines():
    	if line: # 忽略空行
    		decoded_line = line.decode("utf-8") # 解码响应内容,正确显示中文等字符
    		if decoded_line == "data: [DONE]": # 识别结束标识
    			break
    		json_str = decoded_line[len("data: "):] # 通过字符串截取获得JSON部分
    		try:
    			chunk = json.loads(json_str)
    			if chunk["usage"]: # 识别用量信息
    				print(chunk["usage"])
    			choice = chunk["choices"][0] # 获得choice部分
    			if choice["finish_reason"] and choice["finish_reason"] != "stop": # 识别异常结束的情况
    				print(f"意外终止:{choice["finish_reason"]}")
    				break
    			delta = choice["delta"] # 获得delta部分
    			if delta["content"]: # 判断content是否存在
    				print(delta["content"], end="", flush=True) # 打印增量输出
    			if delta["reasoning_content"]: # 判断reasoning_content是否存在
    				print(delta["reasoning_content"], end="", flush=True) # 打印增量输出
    		except Exception as e:
    			print(f"\n无法解析数据:{e}")

    如此,我们便实现了基本的API访问,同时做到了兼容不同的模型。但是,这样的输出对用户不够友好,我们可以尝试为以上实现增加更多功能,并借助Visual Studio Code实现Markdown语法和数学公式的实时渲染。

    Python
    import requests
    import datetime
    import json
    
    auth = {
      'Content-Type': 'application/json',
      'Accept': 'application/json',
      'Authorization': 'Bearer <yourapikey>'
    }
    
    output_path = "<output.md>"
    model = "deepseek-chat"
    user_input = r"""
    
    """
    system_prompt = "你是一位有用的助手,请尽可能准确地回答问题。"
    
    content_start = False
    content_started = False
    response_text = ""
    
    def reasoning(): # 判断并返回思维链内容
        if "reasoning_content" in delta:
            return delta["reasoning_content"]
        else:
            return False
    
    body = {
        "messages" : [
            {
                "role":"system",
                "content":system_prompt
            },
            {
                "role":"user",
                "content":user_input
            }
        ],
        "model":model,
        "frequency_penalty" : 0,
        "max_tokens" : 8192,
        "presence_penalty" : 0,
        "stream":True,
        "stream_options":{
            "include_usage":True
        },
        "temperature":1.1
    }
    
    response = requests.post("https://api.deepseek.com/chat/completions", headers=auth, json=body, stream=True)
    
    if response.status_code != 200: # 仅当请求成功时执行后续逻辑
        print(f"请求失败:{response.text}")
        exit()
    else:
        with open(output_path, 'w') as file: # 清空输出用文件
            pass
    
    now = datetime.datetime.now()
    time_str = now.strftime("%Y-%m-%d %H-%M-%S") # 获取时间戳
    
    with open(output_path, 'a', encoding='utf-8') as file:
        file.write("\n___\n"+time_str+"\n\n"+f"用户输入:\n```{user_input}```\n") # 在输出文件中写入用户时间戳和输入内容
    
    # 根据模型的不同输出提示前缀
    if model == "deepseek-reasoner":
        response_text += "Reasoning content:\n"
        print("Reasoning content:")
        with open(output_path, 'a' ,encoding='utf-8') as file:
            file.write("\n\nReasoning content:\n\n")
    elif model == "deepseek-chat":
        response_text += "Content:\n"
        print("Content:")
        with open(output_path, 'a' ,encoding='utf-8') as file:
            file.write("\n\nContent:\n\n")
    
    for line in response.iter_lines():
        if line:
            decoded_line = line.decode("utf-8")
            if decoded_line == "data: [DONE]":
                price = int(usage["prompt_cache_hit_tokens"]) * 0.0000002 + int(usage["prompt_cache_miss_tokens"]) * 0.000002 + int(usage["completion_tokens"]) * 0.000003
                usage_info = f"\n\nUsage: 输入: {usage["prompt_tokens"]}, 输出: {usage["completion_tokens"]}, 全部: {usage["total_tokens"]}, 缓存命中: {usage["prompt_cache_hit_tokens"]}, 消耗:{price:.6f}" # 根据定价计算本次用量,具体参数请以当前API价格为准
                print(usage_info)
                response_text += usage_info
                break
            json_str = decoded_line[len("data: "):]
            try:
                chunk = json.loads(json_str)
                if chunk["usage"]:
                    usage = chunk["usage"]
                choice = chunk["choices"][0]
                if choice["finish_reason"] and choice["finish_reason"] != "stop":
                    print(f"意外终止:{choice["finish_reason"]}")
                    break
                delta = choice["delta"]
                if delta["content"] and not reasoning(): # 在推理模式下标记正式输出开始
                    content_start = True
                if content_start == True and content_started == False and model == "deepseek-reasoner": # 仅当思维链结束正式输出开始时执行
                    print("\n\nContent:")
                    response_text += "\n\nContent:\n"
                    with open(output_path, 'a' ,encoding='utf-8') as file:
                        file.write("\n\nContent:\n")
                    content_started = True
                if delta["content"]:
                    print(delta["content"], end="", flush=True)
                    with open(output_path, 'a', encoding='utf-8') as file:
                        file.write(delta["content"])
                    response_text += delta["content"]
                if reasoning():
                    print(reasoning(), end="", flush=True)
                    with open(output_path, 'a', encoding='utf-8') as file:
                        file.write(reasoning())
                    response_text += reasoning()
            except Exception as e:
                print(f"\n无法解析数据:{e}")
    
    with open(output_path, 'r+', encoding='utf-8') as file: # 将输出中的LaTeX公式替换为vscode可识别的类型
        read_temp = file.read()
        write_temp = read_temp.replace("\\(", "$")
        write_temp = write_temp.replace("\\)", "$")
        write_temp = write_temp.replace("\\[", "$$")
        write_temp = write_temp.replace("\\]", "$$")
    with open(output_path, 'w', encoding='utf-8') as file:
        file.write(write_temp)
    
    with open("<pathtoyour>\\output_achieves\\"+time_str+".md","w",encoding="utf-8") as f: # 将此次对话写入存档文件
        response_text = response_text.replace("\\(", "$")
        response_text = response_text.replace("\\)", "$")
        response_text = response_text.replace("\\[", "$$")
        response_text = response_text.replace("\\]", "$$")
        f.write(f"用户输入:\n```{user_input}```\n{response_text}")
    
    print(f"\n生成{time_str}.md")

    效果如下:

    至此,我们便获得了一个稳定好用的DeepSeek访问实现。

  • OpenConnect部署——将网卡代理纳入路由

    许多公司与高校都在使用Cisco’s AnyConnect作为为员工师生提供外部上网的解决方案,然而,这类VPN客户端通过创建虚拟网络接口并修改路由策略来实现网络代理,开启后将会接管设备上所有网络流量。如此一来,不但正常的网络连接会被意外代理,导致上网体验受到影响,在开关虚拟网络时也会导致短暂断网,着实不够优雅。

    作为一种解决思路,我们可以通过在远程Linux设备上运行OpenConnect创建虚拟网络接口,并使用自定义脚本配置路由规则来实现更加灵活的分流,同时为其他设备快速接入,实现无感上网提供支持。

    本文,我们将以虹梅师专的校园网VPN为例,演示如何将网卡代理纳入个人网络路由。


    准备开始

    插入一条来自未来的Hacinsl的友情提醒:如果您也在尝试为自己的,尤其是和鄙人同一所学校的校园网配置VPN,可能会更想要先跳转到这个位置看一看踩坑实录。

    首先,在Linux设备上安装openconnect软件包。

    Bash
    sudo apt install openconnect

    尝试连接。

    Bash
    sudo /usr/sbin/openconnect "<yourvpnserver>"

    回车!

    hacinsl@Misaka:~> /usr/sbin/openconnect  vpn-cn.ecnu.edu.cn 
    POST https://vpn-cn.ecnu.edu.cn/
    Connected to 202.120.88.66:443
    SSL negotiation with vpn-cn.ecnu.edu.cn
    Connected to HTTPS on vpn-cn.ecnu.edu.cn with ciphersuite (TLS1.2)-(ECDHE-X25519)-(RSA-SHA256)-(AES-256-GCM)
    Got HTTP response: HTTP/1.1 404 Not Found
    Unexpected 404 result from server
    GET https://vpn-cn.ecnu.edu.cn/
    Connected to 202.120.88.66:443
    SSL negotiation with vpn-cn.ecnu.edu.cn
    Connected to HTTPS on vpn-cn.ecnu.edu.cn with ciphersuite (TLS1.2)-(ECDHE-X25519)-(RSA-SHA256)-(AES-256-GCM)
    Got HTTP response: HTTP/1.0 302 Object Moved
    GET https://vpn-cn.ecnu.edu.cn/+webvpn+/index.html
    SSL negotiation with vpn-cn.ecnu.edu.cn
    Connected to HTTPS on vpn-cn.ecnu.edu.cn with ciphersuite (TLS1.2)-(ECDHE-X25519)-(RSA-SHA256)-(AES-256-GCM)
    Got HTTP response: HTTP/1.1 301 Moved Permanently
    GET https://vpn-cn.ecnu.edu.cn/+CSCOU+/anyconnect_unsupported_version.html
    Please upgrade your AnyConnect Client
    Failed to complete authentication

    Boom!

    Why Boom?

    怎么回事呢?原来是Please upgrade your AnyConnect Client,提示所用的客户端版本不受支持。然而问题不在我,并不是客户端版本太低,而是不在受支持的版本列表内,换句话说,是太高了。

    那么去看一眼虹梅师专的使用说明里用的是什么版本呢,哦,是v5.02,发布于2014年……

    虽然很想吐槽,但对于一个能正常使用的系统,不去乱动它显然是明智之举(?)。

    对于这个问题,最直接的解决方案显然是换用老版本客户端,但Debian12的官方软件源显然不会支持如此古典的版本,比起寻找和添加新软件源的麻烦,也许自己编译才更为省事。

    是的没错我又踩坑了,以下内容仅供参考。

    在官方的发布页面找到v5.02的源代码,解压到合适的目录,执行以下操作进行编译。

    Bash
    cd ./openconnect
    ./autogen.sh
    ./version.sh version.c
    ./configure
    make

    回车!

    In file included from openconnect-internal.h:35,
                     from dtls.c:37:
    /usr/include/openssl/ssl.h:2041:50: note: declared here
     2041 | OSSL_DEPRECATEDIN_1_1_0 __owur const SSL_METHOD *DTLSv1_client_method(void);
          |                                                  ^~~~~~~~~~~~~~~~~~~~
    dtls.c:161:38: error: invalid use of incomplete typedef ‘SSL_SESSION’ {aka ‘struct ssl_session_st’}
      161 |                 vpninfo->dtls_session->ssl_version = 0x0100; /* DTLS1_BAD_VER */
          |                                      ^~
    dtls.c:165:30: error: invalid use of incomplete typedef ‘SSL_SESSION’ {aka ‘struct ssl_session_st’}
      165 |         vpninfo->dtls_session->master_key_length = sizeof(vpninfo->dtls_secret);
          |                              ^~
    dtls.c:166:37: error: invalid use of incomplete typedef ‘SSL_SESSION’ {aka ‘struct ssl_session_st’}
      166 |         memcpy(vpninfo->dtls_session->master_key, vpninfo->dtls_secret,
          |                                     ^~
    dtls.c:169:30: error: invalid use of incomplete typedef ‘SSL_SESSION’ {aka ‘struct ssl_session_st’}
      169 |         vpninfo->dtls_session->session_id_length = sizeof(vpninfo->dtls_session_id);
          |                              ^~
    dtls.c:170:37: error: invalid use of incomplete typedef ‘SSL_SESSION’ {aka ‘struct ssl_session_st’}
      170 |         memcpy(vpninfo->dtls_session->session_id, vpninfo->dtls_session_id,
          |                                     ^~
    dtls.c:187:21: warning: assignment discards ‘const’ qualifier from pointer target type [-Wdiscarded-qualifiers]
      187 |         dtls_cipher = sk_SSL_CIPHER_value(ciphers, 0);
          |                     ^
    dtls.c:190:30: error: invalid use of incomplete typedef ‘SSL_SESSION’ {aka ‘struct ssl_session_st’}
      190 |         vpninfo->dtls_session->cipher = dtls_cipher;
          |                              ^~
    dtls.c:191:30: error: invalid use of incomplete typedef ‘SSL_SESSION’ {aka ‘struct ssl_session_st’}
      191 |         vpninfo->dtls_session->cipher_id = dtls_cipher->id;
          |                              ^~
    dtls.c:191:55: error: invalid use of incomplete typedef ‘SSL_CIPHER’ {aka ‘struct ssl_cipher_st’}
      191 |         vpninfo->dtls_session->cipher_id = dtls_cipher->id;
          |                                                       ^~
    dtls.c:200:51: error: invalid use of incomplete typedef ‘SSL_SESSION’ {aka ‘struct ssl_session_st’}
      200 |                              vpninfo->dtls_session->ssl_version);
          |                                                   ^~
    openconnect-internal.h:338:75: note: in definition of macro ‘vpn_progress’
      338 | #define vpn_progress(vpninfo, ...) (vpninfo)->progress((vpninfo)->cbdata, __VA_ARGS__)
          |                                                                           ^~~~~~~~~~~
    make[1]: *** [Makefile:815: openconnect-dtls.o] Error 1
    make[1]: Leaving directory '/home/hacinsl/openconnect'
    make: *** [Makefile:969: all-recursive] Error 1

    Boom!

    这次又怎么了呢?原来,用小了十岁(指版本发布日期)的OpenSSL去编译OpenConnect会因为ssl-dtls的兼容性而产生问题。

    难道为此还要再去编译一份古典OpenSSL吗?

    也许不至于此。抱着试一试的心态,我下载了最新版OpenConnect的源码,并尝试修改其中的版本标识符来绕过客户端版本检测。

    这次成功了(至少对我来说),以下内容可供实践。

    先把之前编译失败的废品删掉,然后下载解压新的源码,运行官方的生成脚本。

    Bash
    cd ./openconnect
    ./autogen.sh
    ./version.sh version.c

    到这一步打住。手动修改刚刚生成的version.c,将其中的版本号改为5.02,致敬经典。

    然后继续编译操作。

    Bash
    ./configure
    make

    这次没出什么幺蛾子。连接VPN试试。

    hacinsl@hacserver:~> /home/hacinsl/openconnect/openconnect  vpn-cn.ecnu.edu.cn 
    POST https://vpn-cn.ecnu.edu.cn/
    Connected to 202.120.88.66:443
    SSL negotiation with vpn-cn.ecnu.edu.cn
    Connected to HTTPS on vpn-cn.ecnu.edu.cn with ciphersuite TLSv1.2-ECDHE-RSA-AES256-GCM-SHA384
    XML POST enabled
    GROUP: [ECNU]:ECNU
    POST https://vpn-cn.ecnu.edu.cn/
    XML POST enabled
    Username:

    这次顺利来到了登录阶段。搞定。

    流程自动化

    如果每次连接VPN都要手动输入账户密码就丧失了这项工程的意义,好在OpenConnect支持从标准输入(stdin)读取密码, 方便用户通过脚本自动化登录流程。

    编写登录脚本。

    Bash
    #!/bin/bash
    # start_vpn.sh
    echo "<yourpasswd>" | /usr/sbin/openconnect \
      --user=<yourid> \
      --passwd-on-stdin \
      --authgroup=<yourgroup> \
      --script=</path/to/your/configscript.sh> \
      --interface=tun0 \
      "<yourvpnhost>"

    通过自定义OpenConnect启动时调用的脚本,可以防止OpenConnect在建立连接后修改设备默认路由导致的意外代理,但也同时要求用户合理配置设备路由表和路由规则,让目标流量正确通过虚拟网络接口出站。

    为实现这一需求,我们编写一个网络配置脚本,在OpenConnect启动时调用。

    Bash
    #!/bin/bash
    # configscript.sh
    
    TUNNAME="${TUNNAME:-tun0}" # 为防止openconnect在未建立连接时不能正确传递接口名称,在此设定默认值。
    VPN_SERVER="<vpnhost>" # 如果VPN服务器本身也在目标网段内,则需要特别注明,让通往服务器的流量通过物理网络接口出站。
    CERNET_IP="<targetip>" # 将希望通过虚拟网络接口出站的目标网段写入,若为校园网配置,则可直接将学校所在教育网网段写入。
    
    echo "设置接口和IP地址"
    ip link set "$TUNNAME" up
    ip addr add "$INTERNAL_IP4_ADDRESS" dev "$TUNNAME" # 这里只配置了接口的IPv4地址,如有需要也可配置IPv6,前提是VPN服务器提供了IPv6地址,反正虹梅师专没有。
    
    echo "创建自定义路由表"
    TABLE_NAME="vpn"
    TABLE_NUM=1016
    
    echo "检查表是否存在"
    grep -q "^$TABLE_NUM $TABLE_NAME" /etc/iproute2/rt_tables 2>/dev/null || {
      echo "$TABLE_NUM $TABLE_NAME" | sudo tee -a /etc/iproute2/rt_tables
    }
    
    echo "清空自定义表中的默认路由"
    ip route flush table $TABLE_NUM 2>/dev/null
    
    echo "在自定义路由表中添加默认路由,指向VPN接口"
    ip route add default dev "$TUNNAME" table $TABLE_NUM # 让进入该路由表的流量默认都从VPN接口出站。
    
    echo "添加路由规则"
    ip rule add to "$VPN_SERVER" table main priority 100 # 设置高优先级规则,对于和VPN服务器直接通信的流量通过物理接口出站。
    ip rule add to "$CERNET_IP" table $TABLE_NUM priority 200 # 次高优先级规则,对于目标地址为目标网段的流量通过自定义路由表走VPN接口出站。
    
    echo "VPN 接口 $TUNNAME 已启动,策略路由已配置。"

    到这一步,理论上就可以通过登录脚本创建VPN接口了,本设备上去往目标网段的流量都会通过VPN接口出站。

    接下来就是用适当的方式让自己的上网设备将相关流量转发到这台Linux设备上处理,方法不一而足,本文不再讨论。

    更进一步

    通过制定教育网网段的方法分流已经能避免大部分问题,但还不够好,部分部署于公网的学术资源/教育优惠仍然会查验访问者的IP。鉴于此,我们需要一个更强大的,最好是基于域名分流的方案。

    下面演示一个基于Xray和nftables的实现路径。

    为什么不用iptables?这年头连OpenWrt都用上nftables了,还是赶紧拥抱更新更好的防火墙罢。

    Linux的路由规则/Netfilter框架显然不具备处理域名的能力(指嗅探流量中的域名而不是在配置规则时用域名代替IP),需要Xray的辅助。

    一种常见的用于网关设备的代理方案是在PREROUTING链上添加规则,将网关接受的流量通过TPROXY发往本机地址,并配合标记(mark)和策略路由实现透明代理。为了代理本机流量,这种方案选择在OUTPUT链上将本机发出的流量也打上相同的标记,通过策略路由再次导入PREROUTING链处理。

    由于nftables不允许在目标地址转换(DNAT)和重定向(REDIRECT)后继续更多操作(如标记),所以本文采用PREROUTING链上透明代理的方式。也有可能有更优雅的姿势但是我不知道

    Bash
    # create route table for xray
    echo "Create xray table..."
    grep -q "^114 xray" /etc/iproute2/rt_tables 2>/dev/null || {
      echo "114 xray" | tee -a /etc/iproute2/rt_tables
    } # add 114 table if not exist
    grep -q "^116 xray6" /etc/iproute2/rt_tables 2>/dev/null || {
      echo "116 xray6" | tee -a /etc/iproute2/rt_tables
    } # add 116 table if not exist
    ip route flush table 114 2>/dev/null # flush 114 table
    ip route add local 0.0.0.0/0 dev lo table 114 # add default route for 114
    ip rule add fwmark 1 table 114 priority 99 # add rule to mark package
    ip -6 route flush table 116 2>/dev/null # flush 116 table
    ip -6 route add local ::/0 dev lo table 116 # add default route for 116
    ip -6 rule add fwmark 1 table 116 priority 99 # add rule to mark package
    
    # add nft rules
    echo "Create nft rules..."
    nft add table inet xray
    nft add chain inet xray prerouting { type filter hook prerouting priority mangle \; }
    nft flush chain inet xray prerouting
    nft add rule inet xray prerouting ip daddr { 0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4, 240.0.0.0/4 } return
    nft add rule inet xray prerouting ip6 daddr { ::/128, ::1/128, ::ffff:0:0/96, 64:ff9b::/96, 100::/64, 2001::/32, 2001:20::/28, 2001:db8::/32, 2002::/16, fc00::/7, fe80::/10, ff00::/8 } return
    nft add rule inet xray prerouting tcp dport 0-65535 tproxy ip to 0.0.0.0:12345 meta mark set 1 accept
    nft add chain inet xray output { type filter hook output priority mangle \; }
    nft flush chain inet xray output
    nft add rule inet xray output ip daddr { 0.0.0.0/8, 10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4, 240.0.0.0/4 } return
    nft add rule inet xray output ip6 daddr { ::/128, ::1/128, ::ffff:0:0/96, 64:ff9b::/96, 100::/64, 2001::/32, 2001:20::/28, 2001:db8::/32, 2002::/16, fc00::/7, fe80::/10, ff00::/8 } return
    nft add rule inet xray output meta mark 9 return
    nft add rule inet xray output tcp dport 0-65535 meta mark set 1 accept

    Xray相关配置如下。

    JSONC
    {
      "inbounds": [
        {
          "port": 12345,
          "protocol": "dokodemo-door", // 任意门(迫真)
          "settings": {
            "network": "tcp",
            "followRedirect": true
          },
          "sniffing": {
            "enabled": true,
            "destOverride": ["http", "tls"]
          },
          "streamSettings": {
            "sockopt": {
              "tproxy": "tproxy"
            }
          },
          "tag": "tproxy"
        }
      ],
      "outbounds": [
        {
          "protocol": "freedom",
          "tag": "direct",
          "streamSettings": {
              "sockopt": {
                "mark": 9 // 标记直连流量,该outbound置顶作为默认出站
            }
          }
        },
        {
          "protocol": "freedom",
          "tag": "proxy-ecnu",
          "streamSettings": {
              "sockopt": {
                "mark": 3
            }
          } 
        }
      ],
      "dns": {
        "servers": ["223.5.5.5"]
      },
      "routing": {
        "domainStrategy": "AsIs",
        "rules": [
          {
            "type": "field",
            "outboundTag": "proxy-ecnu",
            "domain": [
              "domain:lib.ecnu.edu.cn"
            ]
          }
        ]
      }
    }

    由此,我们可以更新将流量导入openconnect的方式,从匹配出站流量到匹配Xray的标记。

    Bash
    # create route table for ecnu
    echo "Create ecnu table..."
    grep -q "^1016 ecnu" /etc/iproute2/rt_tables 2>/dev/null || {
      echo "1016 ecnu" | tee -a /etc/iproute2/rt_tables
    } # add 1016 table if not exist
    ip route flush table 1016 2>/dev/null # flush 1016 table
    ip route add default dev tun0 table 1016 # add default route for 1016
    ip rule add fwmark 3 table 1016 priority 90 # add rule to mark package

    添加默认路由的操作会因对应虚拟网卡接口不存在而无法进行,因此,应当把上述内容放在openconnect的配置脚本里,或者在启动时检测接口是否存在。以下是一种检测接口和Xray进程的不太靠谱的思路。

    Bash
    # wait for openconnect and xray to start
    while ! pidof xray > /dev/null 2>&1; do
        sleep 1
    done
    echo "xray started"
    while ! ip addr show 2>/dev/null | grep -q "tun0"; do
        sleep 1
    done
    sleep 5
    echo "tun0 found"

    因为接口出现并不代表就能被使用,因此添加了一定的延时以确保执行后续操作时接口已经就绪。

    连接保持

    有些VPN服务器可能会自动关闭一段时间内没有流量的连接,为了保证服务随时可用,我们可以创建一个定时任务,不断请求目标网段,防止连接被意外关闭。

    Bash
    #!/bin/bash
    # keep_alive.sh
    curl "<properwebsite>"
    Bash
    sudo crontab -e
    # 防止掉线
    */3 * * * * /opt/keep_alive.sh
    
    # 开机自启
    @reboot /opt/start_vpn.sh
  • C语言转换说明及其修饰符——格式化输入输出

    printf()函数

    C语言中的printf函数使用转换说明(Conversion Specifier)来格式化输出。一个完整的格式控制符通常以百分号 % 开头,后跟一个或多个字符来指定输出的类型和格式。

    其基本结构可以理解为:%[标志][宽度][.精度][长度修饰符]转换说明符。前四个可选参数统称为转换说明修饰符。

    转换说明符

    用于指定输出数据类型。可单独使用。

    转换说明符含义
    d输出有符号十进制整数,i与其等价。单独使用时参数类型应为int
    u输出无符号十进制整数。单独使用时参数类型应为unsigned int
    f输出十进制单精度/双精度浮点数。默认精确到小数点后六位。单独使用时参数类型应为floatdouble
    a输出用p记数法表示的十六进制浮点数。若改为A则将输出内容中的小写字母替换为大写。单独使用时参数类型应为floatdouble
    g根据值的不同,自动判断是否使用科学计数法。科学计数法默认在指数小于-4时使用;若指定精度,也会在指数大于或等于精度时使用。若改为G则将输出内容中的e替换为E。
    单独使用时参数类型应为floatdouble
    e输出科学计数法形式的浮点数。若改为E则将输出内容中的e替换为E。
    o输出无符号八进制整数,不含前缀。单独使用时参数类型应为unsigned int,若传入有符号整数则输出其八进制补码。
    x输出无符号十六进制整数,不含前缀。若改为X则将输出内容中的小写字母替换为大写。单独使用时参数类型应为unsigned int,若传入有符号整数则输出其十六进制补码。
    c输出一个字符。参数应为int,在使用时被转换为unsigned char
    s输出一个字符串。参数应为指向数组的指针。
    p输出指针地址。输出内容为十六进制数,包含前缀。

    标志

    用于控制输出的对齐、前缀等。需要紧跟在 % 后面使用。

    标志字符含义
    -左对齐,在指定字段宽度的右侧填充空格。
    +为数据显示正负号,对unsigned型不生效。
    如果输出值为非负数且没有符号则添加空格。在同时指定+标志时被忽略。
    #对于八进制和十六进制分别添加0和0x/X的前缀;对于小数则强制输出小数点,即使后面没有数字。
    0使用0而不是空格进行填充。如果指定了左对齐标志或对整数指定精度则被忽略。

    字符宽度

    一个数字,位于标志字符之后,指定输出的最小字符宽度。如果输出的字符数小于此宽度,则用空格(若指定了0标志则用0)填充。如果输出的字符超过此宽度则不会生效。

    数据精度

    以一个点号开头,位于字符宽度之后。当输出的数据类型为整数(转换说明字符为d, i, o, u, x, X)时,指定要显示的最小数字位数,不足时在前面补0。当输出的数据类型为浮点数(转换说明字符为f, F, e, E, a, A)时,指定小数点后的位数,不足在后面补0。当转换说明字符为gG时,指定保留的有效数字位数。当输出的数据类型为字符串时(转换说明字符为s),指定将会输出的最大字符数。

    特别地,若将字符宽度和数据精度用*代替,则会采用参数列表中在输出内容之前的两项内容来代替。

    长度修饰符

    位于数据精度之后,转换说明符之前。用于指定输出数据的大小以便处理不同长度的数据。

    长度修饰符含义
    h表示shortunsigned short类型。适用于所有整数转换说明符。
    hh表示signed charunsigned char类型。适用于所有整数转换说明符。
    l表示longunsigned long。适用于所有整数转换说明符。
    ll表示long longunsigned long long。适用于所有整数转换说明符。
    L表示long double。适用于所有浮点转换说明符。
    z表示size_tsizeof运算符返回)。
    t表示ptrdiff_t(指针相减返回)。

    示例

    接下来看一些使用例:

    C
    // 单独使用转换说明符
    #include <stdio.h>
    int main(void)
    {
    	int a = 87;
    	unsigned int b = 87;
    	float c = 3.14;
    	double d = 2.71;
    	float e = 1919810;
    	unsigned int f = 87;
    	unsigned int g = 114514;
    	int h = 65;
    	char i[] = "Hello World.";
    	long *j = &a;
    
    	printf("%d\n", a);
    	printf("%u\n", b);
    	printf("%f %f\n", c, d);
    	printf("%a %A %g %G\n", c, c, c * 0.00001, e);
    	printf("%e\n", e);
    	printf("%o\n", f);
    	printf("%x %X\n", g, g);
    	printf("%c\n", h);
    	printf("%s\n", i);
    	printf("%p", (void *)j);
    	return 0;
    }

    输出为:

    87
    87
    3.140000 2.710000
    0x1.91eb860000000p+1 0X1.91EB860000000P+1 3.14e-05 1.91981E+06
    1.919810e+06
    127
    1bf52 1BF52
    A
    Hello World.
    0000008DB1B7F644
    C
    // 使用字符宽度和标志
    #include <stdio.h>
    int main(void)
    {
    	char str[] = "Ciallo~";
    	int a = 87;
    	int b = -87;
    	int c = 114514;
    	float d = 10;
    
    	printf("★%-10s\n", str);
    	printf("★%+10d\n", a);
    	printf("★%+10d\n", b);
    	printf("★% -10d\n", a);
    	printf("★% -10d\n", b);
    	printf("★%#10o\n", c);
    	printf("★%#10x\n", c);
    	printf("★%#10X\n", c);
    	printf("★%10.0f\n", d);
    	printf("★%#10.0f\n", d);
    	printf("★%010d★", a);
    	return 0;
    }

    输出为:

    ★Ciallo~   ★
    ★       +87★
    ★       -87★
    ★ 87       ★
    ★-87       ★
    ★   0337522★
    ★   0x1bf52★
    ★   0X1BF52★
    ★        10★
    ★       10.★
    ★0000000087★
    C
    // 使用数据精度
    #include <stdio.h>
    int main(void)
    {
    	short a = 10;
    	unsigned short b = 65535;
    	signed char c = -128;
    	double d = 3.1415926;
    	double e = 12345.678;
    	double f = 0.0000000123;
    	double g = 1000000000000000.0;
    
    	printf("%.5d\n", a);
    	printf("%.3d\n", b);
    	printf("%.5d\n", c);
    	printf("%.3f\n", d);
    	printf("%.10f\n", d);
    	printf("%.3G\n", d);
    	printf("%.5g\n", e);
    	printf("%.5G\n", f);
    	printf("%.6g", g);
    	return 0;
    }

    输出为

    00010
    65535
    -00128
    3.142
    3.1415926000
    3.14
    12346
    1.23E-08
    1e+15
    C
    // 使用长度修饰符
    #include <stdio.h>
    int main(void)
    {
    	short a = 10;
    	unsigned short b = 65535;
    	signed char c = -128;
    	unsigned char d = 255;
    	long e = 2147483647;
    	unsigned long f = 4294967294;
    	long long g = 9223372036854775806;
    	unsigned long long h = 18446744073709551615;
    	long double i = 3.14159265358979;
    
    	printf("%hd\n", a);
    	printf("%hu\n", b);
    	printf("%hhd\n", c);
    	printf("%hhu\n", d);
    	printf("%ld\n", e);
    	printf("%lu\n", f);
    	printf("%lld\n", g);
    	printf("%llu\n", h);
    	printf("%.15Lf\n", i);
    	printf("%zd\n", sizeof h);
    	printf("%td", &b - &a);
    	return 0;
    }

    输出为:

    10
    65535
    -128
    255
    2147483647
    4294967294
    9223372036854775806
    18446744073709551615
    3.141592653589790
    8
    16

    接下来玩点花的:

    C
    // 大杂烩
    #include <stdio.h>
    int main(void)
    {
    	long double a = 3.1415926535;
    	unsigned long long b = 219845122340;
    	printf("★%+*.*llu\n", 20, 15, b);
    	printf("★% #020.0Lf\n", a);
    	printf("%%"); // 直接打印百分号
    	return 0;
    }

    输出为:

    ★     000219845122340★
    ★ 000000000000000003.★
    %

    scanf()函数

    scanf()作为处理输入的函数,其转换说明由转换说明符、抑制赋值标志、字符宽度和长度修饰符组成。scanf()函数所用的转换说明与printf()函数几乎相同,但对浮点类型和符号的处理略有差异。

    转换说明符

    转换说明符含义
    d把输入解释为有符号十进制整数,i与其等价。单独使用时地址参数对应的类型应为int
    u把输入解释为无符号十进制整数。单独使用时地址参数对应的类型应为unsigned int
    f,e,a,g把输入解释为浮点数。单独使用时地址参数对应的类型应为float
    o把输入解释为有符号八进制整数。单独使用时地址参数对应的类型应为int
    x把输入解释为有符号十六进制整数。若改为X则适用于输入数字中的字母为大写的情形。单独使用时地址参数对应的类型应为int
    c把输入解释为一个字符,不会忽略空白字符。地址参数对应的类型应为char
    s把输入解释为字符串,遇到空白字符即停止。参数应为指向数组的指针。
    p把输入解释为指针地址。参数应为一个指针的地址。

    抑制赋值

    *表示,放在%后,若使用此标志,则会在读取输入时跳过对应的内容。

    C
    // 使用抑制赋值标志
    #include <stdio.h>
    int main(void)
    {
    	int a;
    	scanf("%*d %*d %d", a);
    	return 0;
    }

    可接受如下输入:

    10 12 15

    a会被赋值为15。

    字符宽度

    一个数字,位于长度修饰符前,表示输入的最大字符宽度。对输入的读取会在达到此宽度或遇到空白字符时停止。

    长度修饰符

    位于字符宽度之后,转换说明符之前。

    长度修饰符含义
    h表示shortunsigned short类型。适用于所有整数转换说明符。
    hh表示signed charunsigned char类型。适用于所有整数转换说明符。
    l用于整数转换说明符时表示longunsigned long,用于浮点转换说明符时表示double
    ll表示long longunsigned long long。适用于所有整数转换说明符。
    L表示long double。适用于所有浮点转换说明符。
    z表示size_tsizeof运算符返回)。
    t表示ptrdiff_t(指针相减返回)。
  • WordPress全站静态化

    对于想要一个简单的个人博客,又对静态站点较为繁琐的发布流程感到厌烦的人来讲,上手简单、功能完备的WordPress也许是一个不错的选择。

    然而,搭建一个动态站点需要PHP和数据库环境,这对于内存和性能捉襟见肘的廉价云主机而言并不现实。何况在公网环境下,动态站点所面临的安全风险更是显著大于只有静态文件的网站。

    那么,既然个人博客的一切操作都只由自己完成,为什么不能让WordPress运行在本地,定期生成静态站点交由远程主机对外访问呢?本文就是要探讨一种将WordPress全站静态化的实现方式。

    而且,不花钱。


    材料准备

    • 一台具备公网访问能力的廉价主机
    • 一台搭建了WordPress站点的电脑(本文示例安装Linux系统)

    正式开始

    首先,我们要解决如何生成静态站点的问题。

    Simply Static可以帮助我们便捷地生成静态文件。安装并启用该插件,在插件页面-SETTINGS-General调整相关配置,然后点击Gernerate即可。

    我个人的习惯是将/wp-content/uploads/目录加入Additional Files and Directories,以便显示一些媒体文件和额外脚本。

    需要注意的是,Simply Static插件存储临时文件和静态站点的目录在/uploads下,如果将uploads目录加入额外文件,会导致更新静态站点时额外包含我们不希望出现的文件。下文进行自动化实现时将会采取额外操作以避免此问题。

    在生成完成后,理论上/www/wwwroot/wp/wp-content/uploads/simply-static/temp-files下就会出现静态站点的内容。其中的目录里是文件形式的网站内容,同时还会生成前者的压缩形式。进行同步时按需进行选择。

    接下来要搞定自动同步。我们没必要要求自己的静态站点实时更新,所以只需定期生成并同步即可。然而,免费版的Simply Static并不支持自动生成,我们需要想办法让「用户点击Generate按钮」的操作自动化。

    正常情况下,当我们点击Generate按钮后,浏览器会向https://your.site/wp-json/simplystatic/v1/start-export?_locale=user

    发送一个POST请求,以此触发服务器的生成操作。

    这样的POST请求当然需要鉴权,通过调试工具可知,该请求的headers部分应当如下所示。

    其中的关键部分是CookieX-WP-Nonce字段。我们接下来的目标便是自动化获取以上信息。

    顺带一提,以下的代码大部分都是找AI搓的。

    首先模拟登录。

    Python
    session = requests.Session()
    login_url = "https://www.hacinsl.top/wp-login.php"
    
    login_data = {
        "log": "username",
        "pwd": "password",
        "wp-submit": "登录"
    }
    session.post(login_url, data=login_data)

    从中提取cookie并转换为JSON样式。

    Python
    def process_cookies(cookie_text):
        pattern = r'<Cookie (\w+)=([^ ]+) for'
        matches = re.findall(pattern, cookie_text)
        
        cookies = [f"{name}={value}" for name, value in matches]
        result = "; ".join(cookies)
        
        return result
    auth_cookies = process_cookies(str(session.cookies)[20:-1])

    模拟访问管理页面并解析JSON以获取WP-Nonce

    Python
    admin_url = "https://www.hacinsl.top/wp-admin/"
    admin_page = session.get(admin_url)
    
    match = re.search(r'var wpApiSettings\s*=\s*({.*?});', admin_page.text, re.DOTALL)
    if match:
        json_str = match.group(1)
        print("找到JSON部分:", json_str)
        
        try:
            data = json.loads(json_str)
            nonce = data.get('nonce')
            print(f"nonce的值是:{nonce} ")
        except json.JSONDecodeError:
            print("JSON解析失败")
    else:
        print("没有找到以'var wpApiSettings ='开头的行")

    构建请求headersbody

    Python
    headers = {
        "Host":"www.hacinsl.top",
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0",
        "Referer": "https://www.hacinsl.top/wp-admin/admin.php?page=simply-static-generate",
        "Content-Type": "application/json",
        "Cookie": auth_cookies,
        "Connection": "keep-alive",
        "X-WP-Nonce":nonce
    }
    
    body = {
        "blog_id":"1",
        "type":"update"
        }

    发送POST请求并输出状态码。

    Python
    response = requests.post("https://www.hacinsl.top/wp-json/simplystatic/v1/start-export?_locale=user",headers=headers, json=body)
    
    print(response)

    等待服务器生成完毕。

    Python
    time.sleep(300)

    以上操作的完整代码如下:

    Python
    import requests
    import json
    import re
    import time
    
    
    def process_cookies(cookie_text):
        pattern = r'<Cookie (\w+)=([^ ]+) for'
        matches = re.findall(pattern, cookie_text)
        
        cookies = [f"{name}={value}" for name, value in matches]
        result = "; ".join(cookies)
        
        return result
    
    session = requests.Session()
    login_url = "https://www.hacinsl.top/wp-login.php"
    admin_url = "https://www.hacinsl.top/wp-admin/"
    
    login_data = {
        "log": "username",
        "pwd": "password",
        "wp-submit": "登录"
    }
    session.post(login_url, data=login_data)
    
    auth_cookies = process_cookies(str(session.cookies)[20:-1])
    print(session.cookies)
    print(auth_cookies)
    
    
    admin_page = session.get(admin_url)
    
    match = re.search(r'var wpApiSettings\s*=\s*({.*?});', admin_page.text, re.DOTALL)
    if match:
        json_str = match.group(1)
        print("找到JSON部分:", json_str)
        
        try:
            data = json.loads(json_str)
            nonce = data.get('nonce')
            print(f"nonce的值是:{nonce} ")
        except json.JSONDecodeError:
            print("JSON解析失败")
    else:
        print("没有找到以'var wpApiSettings ='开头的行")
    
    headers = {
        "Host":"www.hacinsl.top",
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0",
        "Referer": "https://www.hacinsl.top/wp-admin/admin.php?page=simply-static-generate",
        "Content-Type": "application/json",
        "Cookie": auth_cookies,
        "Connection": "keep-alive",
        "X-WP-Nonce":nonce
    }
    
    body = {
        "blog_id":"1",
        "type":"update"
        }
    
    response = requests.post("https://www.hacinsl.top/wp-json/simplystatic/v1/start-export?_locale=user",headers=headers, json=body)
    
    print(response)
    
    time.sleep(300)

    接下来搞定文件同步的部分,使用shell脚本。

    首先删除上次生成遗留的文件。

    Bash
    rm -R /www/wwwroot/wp/wp-content/uploads/simply-static/

    运行刚刚编写的Python程序,需要服务器安装Python环境。

    Bash
    /usr/bin/python3 /opt/wp_static.py

    使用rsync同步本地和远程主机的内容。使用--checksum选项以根据校验值判断文件异同,使用--delete选项以及时删除远程主机上存在的已不再使用的文件,通过密钥登录远程主机以避免输入密码。

    Bash
    /usr/bin/rsync -avz --checksum --delete -e "ssh -i /home/username/.ssh/id_rsa" /www/wwwroot/wp/wp-content/uploads/simply-static/temp-files/simply-static-1-*/ hacinsl@misaka.hacinsl.top:/www/wwwroot/wp/

    以上内容的完整代码如下:

    Bash
    #!/bin/bash
    rm -R /www/wwwroot/wp/wp-content/uploads/simply-static/
    /usr/bin/python3 /opt/wp_static.py
    /usr/bin/rsync -avz --checksum --delete -e "ssh -i /home/hacinsl/.ssh/id_rsa" /www/wwwroot/wp/wp-content/uploads/simply-static/temp-files/simply-static-1-*/ hacinsl@misaka.hacinsl.top:/www/wwwroot/wp/
    echo "Done."

    接下来设置一个定时任务,以root身份每半小时运行一次上述脚本,即可完成远程静态站点的自动更新。

  • 测试

    这是一篇测试文章

  • Hello world!

    Welcome to WordPress. This is your first post. Edit or delete it, then start writing!