7、隧道工具

作者: Brinnatt 分类: ARM64 Linux CICD 研发 发布时间: 2025-01-18 09:39

7.1、openvpn

OpenVPN 官方地址:https://github.com/OpenVPN/openvpn

OpenVPN 的功能很强大,我们并没有深入研究该工具,按照以下步骤配置,基本上够用。

7.1.2、环境准备

软硬件版本

硬件 操作系统 软件
ARM64 ft2000+ centos7.5 easy-rsa3.0.6 openvpn2.4.7

软件安装源

# cat /etc/yum.repos.d/CentOS-Base.repo
[base]
name=CentOS-$releasever - Base
baseurl=https://mirrors.aliyun.com/centos-altarch/$releasever/os/$basearch/
gpgcheck=1
gpgkey=https://mirrors.aliyun.com/centos/RPM-GPG-KEY-CentOS-7

#released updates
[updates]
name=CentOS-$releasever - Updates
baseurl=https://mirrors.aliyun.com/centos-altarch/$releasever/updates/$basearch/
gpgcheck=1
gpgkey=https://mirrors.aliyun.com/centos/RPM-GPG-KEY-CentOS-7

#additional packages that may be useful
[extras]
name=CentOS-$releasever - Extras
baseurl=https://mirrors.aliyun.com/centos-altarch/$releasever/extras/$basearch/
gpgcheck=1
gpgkey=https://mirrors.aliyun.com/centos/RPM-GPG-KEY-CentOS-7
enabled=1

#additional packages that extend functionality of existing packages
[centosplus]
name=CentOS-$releasever - Plus
baseurl=https://mirrors.aliyun.com/centos-altarch/$releasever/centosplus/$basearch/
gpgcheck=1
enabled=0
gpgkey=https://mirrors.aliyun.com/centos/RPM-GPG-KEY-CentOS-7
# cat /etc/yum.repos.d/epel.repo
[epel]
name=Extra Packages for Enterprise Linux 7 - $basearch
#baseurl=http://download.fedoraproject.org/pub/epel/7/$basearch
metalink=https://mirrors.fedoraproject.org/metalink?repo=epel-7&arch=$basearch
failovermethod=priority
enabled=1
gpgcheck=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-EPEL-7

安装软件包

# yum install easy-rsa openvpn -y
Loaded plugins: fastestmirror
Loading mirror speeds from cached hostfile
 * epel: mirrors.yun-idc.com
Package easy-rsa-3.0.6-1.el7.noarch already installed and latest version
Package openvpn-2.4.7-1.el7.aarch64 already installed and latest version
Nothing to do

7.1.3、配置 easy-rsa 3.0.6

安装完 easy-rsa 3.0.6 后,默认会生成 easyrsa 脚本文件和 vars.example 环境变量文件,这两个文件是核心文件,找到这两个文件所在的目录,复制到相应的位置,然后根据自己的需求配置 vars

# find /usr/ -name easyrsa -o -name vars.example
/usr/share/doc/easy-rsa-3.0.6/vars.example
/usr/share/easy-rsa/3.0.6/easyrsa
# cp -r /usr/share/easy-rsa/3.0.6/ /etc/openvpn/3.0.6/
# cp /usr/share/doc/easy-rsa-3.0.6/vars.example /etc/openvpn/3.0.6/vars
# grep -v "^#\|^$" /etc/openvpn/3.0.6/vars
if [ -z "$EASYRSA_CALLER" ]; then
    echo "You appear to be sourcing an Easy-RSA 'vars' file." >&2
    echo "This is no longer necessary and is disallowed. See the section called" >&2
    echo "'How to use this file' near the top comments for more details." >&2
    return 1
fi
set_var EASYRSA_REQ_COUNTRY "CN"
set_var EASYRSA_REQ_PROVINCE    "HuNan"
set_var EASYRSA_REQ_CITY    "ChangSha"
set_var EASYRSA_REQ_ORG "Greatwall Corporation"
set_var EASYRSA_REQ_EMAIL   "yuliangliang@greatwall.com.cn"
set_var EASYRSA_REQ_OU      "Product Department"
[root@openvpn ~]# 

7.1.3.1、easyrsa 命令使用

查看整体帮助
# ./easyrsa -h

查看某个子命令的详细帮助
# ./easyrsa help import-req

查看可以用到的 options
# ./easyrsa help options

7.1.3.2、创建 PKI 和 CA

# cd /etc/openvpn/3.0.6/
# ./easyrsa init-pki
# ls
easyrsa  openssl-easyrsa.cnf  pki  vars  x509-types
# ./easyrsa build-ca nopass (配置好了vars直接回车即可)

执行 ./easyrsa init-pki 后,EasyRSA 会在当前目录下创建一个名为 pki 的目录结构,包含以下内容:

  • pki/private:用于存放私钥文件,权限严格受限,确保密钥的安全。
  • pki/reqs:用于存放证书签名请求(CSR)。
  • pki/issued:用于存放签名后的证书(例如 .crt 文件)。
  • pki/certs_by_serial:存放按序列号命名的已签发证书(通常只在调试或跟踪中使用)。
  • pki/index.txt:一个索引文件,用于跟踪签发的所有证书。
  • pki/serial:一个文件,用于记录下一个将要分配的证书序列号。

命令 ./easyrsa build-ca nopass 是使用 EasyRSA 工具创建一个证书颁发机构(CA, Certificate Authority)的根证书和私钥。它是建立 PKI(公钥基础设施)中最重要的一步。

build-ca:子命令,表示创建一个证书颁发机构(CA)。

  • 该命令会生成 CA 的私钥和根证书。
  • CA 的主要作用是签署证书签名请求(CSR),生成可信任的服务器或客户端证书。
  • 根证书是所有后续签名的基础。

nopass:选项,表示生成的 CA 私钥不设置密码保护。

  • 如果没有 nopass,生成的私钥需要设置一个密码,每次使用时需要输入密码。

运行命令后,会在 pki 目录中生成以下文件:

  1. pki/private/ca.key:CA 的私钥文件,用于签署证书。必须妥善保管,泄露会导致整个 PKI 系统的安全性失效。
  2. pki/ca.crt:CA 的根证书,用于验证由 CA 签发的所有证书。这个文件通常会分发给客户端或服务器,用于信任链的验证。

7.1.3.3、创建服务端证书

# ./easyrsa gen-req server nopass (回车即可)

./easyrsa:这是 EasyRSA 工具的可执行脚本,用于管理私钥、证书、证书请求等。

gen-req:这个子命令用于生成证书签名请求(CSR)。它会同时生成一个私钥文件和一个与之关联的证书签名请求文件。

server:这是证书签名请求的名称(common name, CN)。生成的文件会以这个名字命名,比如:

  • 私钥:server.key
  • CSR 文件:server.req

nopass:这个选项表示生成的私钥文件不需要设置密码保护。如果没有 nopass,生成的私钥文件会要求输入密码才能使用。

7.1.3.4、签约服务端证书

[root@openvpn 3.0.6]# ./easyrsa sign server server

signEasyRSA 子命令,表示对证书签名请求(CSR)进行签名。

server:表示证书的类型。

  • server 类型的证书通常用于服务器(如 OpenVPN 服务器)。
  • EasyRSA 会根据配置为服务器证书添加相关扩展字段(如 extendedKeyUsage = serverAuth)。

server:第二个 server 是证书签名请求(CSR)的名称,与之前通过 gen-req 生成时的名称一致。

  • 它表示要签名的 CSR 文件,通常是 pki/reqs/server.req
  • 签名后的证书会被命名为 server.crt,并保存在 pki/issued/ 目录下。

7.1.3.5、创建 Diffie-Hellman

# ./easyrsa gen-dh

Diffie-Hellman (DH) 是一种密钥交换协议,用于安全生成共享密钥。

  • 在 SSL/TLS 通信中,它用于支持 Perfect Forward Secrecy (PFS),即使私钥泄露,之前的通信数据仍然无法解密。
  • 在 OpenVPN 服务器配置中,DH 参数文件是服务器端密钥交换的一部分,确保客户端与服务器之间的通信加密。

7.1.3.6、客户端证书请求

# ./easyrsa gen-req jiajia nopass

7.1.3.7、签约客户端证书

# ./easyrsa sign client jiajia

注意:这里的客户端证书用到的 pki 目录跟服务器端的 pki 目录是一样的,所以可以直接 sign 签署,如果客户端重新生成了 pki,那每次生成的证书请求文件都需要用 ./easyrsa import-req $PATH/jiajia.req jiajia 导入并取一个短名字 jiajia,然后执行 ./easyrsa sign client jiajia

参见 ./easy-rsa help import-req

7.1.3.8、生成 ta.key 文件

[root@openvpn 3.0.6]# openvpn --genkey --secret ta.key

命令 openvpn --genkey --secret ta.key 是用于生成一个预共享密钥(TA 密钥,TLS Authentication Key)。这在 OpenVPN 中主要用于增强安全性,防止未经授权的连接尝试和某些类型的攻击(如 DoS 攻击)。

--genkey:生成密钥的选项。

--secret ta.key:指定生成的密钥文件名为 ta.key

  • 这个密钥是一个对称密钥,客户端和服务器共享。
  • 文件内容是一个随机生成的 2048 位密钥。

TLS Authentication:TA 密钥用于 OpenVPN 的 TLS 认证模式。在 TLS 握手过程中,只有提供正确的 TA 密钥的客户端才会被服务器接受。

  • 防止未经授权的连接尝试。
  • 增加防火墙规则配置的灵活性。

防御攻击:TA 密钥能有效阻止某些类型的攻击。

  • 防止 DoS 攻击:通过过滤非授权数据包减轻负载。
  • 防止中间人攻击:确保握手数据的完整性。

OpenVPN 2.5.5 版本:
openvpn --genkey secret ta.key

7.1.3.9、整理证书

# pwd
/etc/openvpn/3.0.6
# ll pki/private/
-rw-------. 1 root root 1679 Jun  9 21:05 ca.key
-rw-------. 1 root root 1708 Jun  9 21:06 server.key
# ll pki/issued/
-rw-------. 1 root root 4552 Jun  9 21:18 server.crt
# ll pki/ca.crt 
-rw-------. 1 root root 1172 Jun  9 21:05 pki/ca.crt
# ll pki/dh.pem 
-rw-------. 1 root root 424 Jun  9 21:10 pki/dh.pem
# cp ta.key pki/
# ll pki/ta.key 
-rw-------. 1 root root 636 Jun  9 21:31 pki/ta.key

以上配置文件将会在服务器端和端户端配置文件中指出。

7.1.4、服务器端配置文件

# grep -vE "^#|^$" /etc/openvpn/server.conf 
local 172.18.1.206
port 1194
proto tcp
dev tun
ca /etc/openvpn/3.0.6/pki/ca.crt
cert /etc/openvpn/3.0.6/pki/issued/server.crt
key /etc/openvpn/3.0.6/pki/private/server.key
dh /etc/openvpn/3.0.6/pki/dh.pem
server 10.8.0.0 255.255.255.0
ifconfig-pool-persist ipp.txt
# 向客户端推送路由,客户端会配置172.17.0.0/16 gw 10.8.0.1,这样客户端连接172.17.0.0/16网段会走vpn隧道
push "route 172.17.0.0 255.255.0.0"
keepalive 10 120
# tls-auth ta.key [0|1],服务端 0,客户端 1
tls-auth /etc/openvpn/3.0.6/pki/ta.key 0
cipher AES-256-CBC
comp-lzo
persist-key
persist-tun
status openvpn-status.log
verb 3
reneg-sec 0

# 注意,这个文件用来记录被吊销的证书,阻止被吊销的客户再次登陆
# 默认没有这个文件,必须执行 ./easyrsa gen-crl 更新生成,否则服务启不来
crl-verify /etc/openvpn/3.0.6/pki/crl.pem

7.1.5、启动 openvpn 服务

# systemctl start openvpn@server.service
# systemctl enable openvpn@server.service

7.1.6、配置 iptables 规则

为了可以让客户端访问服务器所在局域网的其它主机,需要配置如下规则:

# iptables -t nat -A POSTROUTING -s 10.8.0.0/24 -j MASQUERADE
# echo "1" > /proc/sys/net/ipv4/ip_forward
# cat /proc/sys/net/ipv4/ip_forward
1

注意:源地址转换的网段使用 10.8.0.0/24,不要使用 10.8.0.0/8,因为网段范围太大会误伤其他 10.x.x.x 段的流量,亲身经历因为配置了 8 掩码干漰了整个 k8s 生产环境。

这条 nat 规则的原理:

  • 局域网内的其他主机(如 172.17.0.100)上并不存在 10.8.0.0/8 这个网段,因为只有 vpn 服务器才有 tun0 隧道接口,是这个网段。
  • VPN 客户端(如 10.8.0.2)向 172.17.0.100 发送数据包。
    • 原始数据包源 IP:10.8.0.2
    • 目的 IP:172.17.0.100
    • 这个包进入 VPN 服务器
  • VPN 服务器 MASQUERADE 将源 IP 10.8.0.2 修改为 VPN 服务器的局域网 IP(如 172.17.0.10)。
    • 172.17.0.10 与 172.17.0.100 属于同网段局域网,相互可以正常收发数据包。
    • VPN 服务器将 172.17.0.10 改回 10.8.0.2,然后转发回 VPN 客户端。

结合 push "route 172.17.0.0 255.255.0.0" 这条配置,路线如下:
客户端访问 172.17.0.0/16 --> vpn 隧道 10.8.0.1 --> 源地址伪装 172.17.0.0/16 --> 局域 172.17.0.0/16 所有主机

生产环境遇到的问题:
就算配置了以上两种规则,vpn 客户端还是不能访问 vpn 服务器所在的局域网,什么原因?
通过排查 iptables 规则,发现 forward 链默认是 DROP,而恰恰从隧道过来去访问 vpn 服务器局域网内其它主机,要走 forward 链,原理参考:https://brinnatt.com/further/第-2-章-linux-防火墙/

解决方法:

# iptables -N OPENVPN_FORWARD_INNER
# iptables -A OPENVPN_FORWARD_INNER -s 10.8.0.0/24 -d 10.58.121.0/24 -j ACCEPT
# iptables -A OPENVPN_FORWARD_INNER -s 10.58.121.0/24 -d 10.8.0.0/24 -j ACCEPT
# iptables -A FORWARD -j OPENVPN_FORWARD_INNER
# iptables -A OPENVPN_FORWARD_INNER -j RETURN

未指定表,默认是处理 filter 表。

7.1.7、客户端的配置文件

client
dev tun
proto tcp
remote 36.158.226.1 16001
resolv-retry infinite
persist-key
persist-tun
mute-replay-warnings
ca ca.crt
cert yll.crt
key yll.key
remote-cert-tls server
tls-auth ta.key 1
cipher AES-256-CBC
comp-lzo
verb 3
mute 20
reneg-sec 0

7.1.8、生成或吊销客户端证书

#!/bin/bash

set -euo pipefail  # 遇到错误、未定义变量或管道失败时终止

dir="/etc/openvpn/3.0.6" # 注意自己使用的easyrsa版本,本文是把版本当作工作目录
certs_dir="/client.certs"

install_expect() {
    rpm -q expect &>/dev/null || yum install -y expect || {
        echo "Error: you must install expect manually!" >&2
        exit 1
    }
}

validate_input() {
    if [[ $# -ne 2 || ! $2 =~ ^[a-zA-Z][a-zA-Z0-9_]{2,15}$ ]]; then
        echo "Usage: $0 {create|revoke} USER_NAME" >&2
        echo "用户名需由字母开头,包含3-16位字母、数字或下划线" >&2
        exit 1
    fi
}

check_existing_user() {
    local username=$1
    pushd "$dir" >/dev/null # 进入$dir工作目录
    if ./easyrsa show-cert "$username" &>/dev/null; then
        popd >/dev/null
        echo "用户 $username 已存在,请重新输入" >&2
        exit 1
    fi
    popd >/dev/null # 退出$dir工作目录
}

check_user_existence() {
    local username=$1
    if [[ ! -d "$dir/pki/issued" || ! -f "$dir/pki/issued/${username}.crt" ]]; then
        echo "没有这个用户,无法删除" >&2
        exit 1
    fi
}

generate_cert_request() {
    local username=$1
    pushd "$dir" >/dev/null

    # 检查并删除该用户残留的请求文件
    if [[ -f "$dir/pki/reqs/${username}.req" ]]; then
        rm -f "$dir/pki/reqs/${username}.req"
    fi

    # 检查并删除该用户残留的私钥文件
    if [[ -f "$dir/pki/private/${username}.key" ]]; then
        rm -f "$dir/pki/private/${username}.key"
    fi

    expect <<-EOF
    spawn ./easyrsa gen-req $username nopass
    expect "$username" { send "\n" }
    expect eof
EOF
    popd >/dev/null
}

sign_cert_request() {
    local username=$1
    pushd "$dir" >/dev/null
    expect <<-EOF
    spawn ./easyrsa sign client $username
    expect "Confirm" { send "yes\n" }
    expect eof
EOF
    popd >/dev/null
}

prepare_client_cert_directory() {
    local username=$1
    rm -rf "$certs_dir/$username"
    mkdir -p "$certs_dir/$username"
}

create_client_ovpn_template() {
    cat > "$certs_dir/clientsample.ovpn" <<-EOF
client
dev tun
proto tcp
remote 61.187.64.38 11940
resolv-retry infinite
persist-key
persist-tun
mute-replay-warnings
ca ca.crt
cert sample.crt
key sample.key
remote-cert-tls server
tls-auth ta.key 1
cipher AES-256-CBC
comp-lzo
verb 3
mute 20
reneg-sec 0
EOF
}

finalize_client_ovpn() {
    local username=$1
    cp "$certs_dir/clientsample.ovpn" "$certs_dir/$username/client.ovpn"
    sed -i "s@sample.crt@${username}.crt@g" "$certs_dir/$username/client.ovpn"
    sed -i "s@sample.key@${username}.key@g" "$certs_dir/$username/client.ovpn"
}

copy_cert_files() {
    local username=$1
    cp "$dir/pki/ca.crt" "$dir/pki/ta.key" "$dir/pki/issued/${username}.crt" "$dir/pki/private/${username}.key" "$certs_dir/$username"
}

revoke_certificate() {
    local username=$1
    pushd "$dir" >/dev/null
    expect <<-EOF
    spawn ./easyrsa revoke $username
    expect {
        "revocation" { send "yes\n" }
    }
    expect eof
EOF
    ./easyrsa gen-crl
    popd >/dev/null
}

delete_user_directory() {
    local username=$1
    local user_dir="$certs_dir/$username"
    if [[ -d "$user_dir" ]]; then
        rm -rf "$user_dir"
    fi
}

main() {
    if [[ $# -lt 2 ]]; then
        echo "Usage: $0 {create|revoke} USER_NAME" >&2
        exit 1
    fi

    local action=$1
    local username=$2

    case "$action" in
        create)
            install_expect
            validate_input "$@"
            check_existing_user "$username"
            generate_cert_request "$username"
            sign_cert_request "$username"
            mkdir -p "$certs_dir"
            create_client_ovpn_template
            prepare_client_cert_directory "$username"
            copy_cert_files "$username"
            finalize_client_ovpn "$username"
            echo "用户 $username 的 OpenVPN 证书已生成!"
            ;;
        revoke)
            validate_input "$@"
            check_user_existence "$username"
            revoke_certificate "$username"
            delete_user_directory "$username"
            echo "用户 $username 的证书已被成功吊销并删除。"
            ;;
        *)
            echo "Invalid action: $action" >&2
            echo "Usage: $0 {create|revoke} USER_NAME" >&2
            exit 1
            ;;
    esac
}

main "$@"

创建 ovpnuser 文件,编写以上脚本,赋于可执行权限,./ovpnuser help 查看用法。

7.1.9、疑问

7.1.9.1、openvpn tls 握手

1、openvpn 使用的是 tcp 或 udp 协议,为什么可以指定证书,也就是可以实现 tls 握手?
TLS 协议的本质,TLS(Transport Layer Security)不是 HTTP 的专属协议,而是一个介于传输层和应用层之间的安全层,其核心功能是:

  • 加密传输层数据:无论上层是 HTTP、FTP、SMTP 还是自定义协议,TLS 都能对其加密。

  • 独立于应用协议:TLS 可以承载任何应用层协议(如 HTTP over TLS 即 HTTPS),也可以直接包裹 TCP/UDP 原始流量。

TLS 的协议栈位置:

  +---------------------+
  |    应用层协议       | 如 HTTP、SMTP、OpenVPN 自定义协议
  +---------------------+
  |        TLS          | ← 加密/解密、证书验证在此层完成
  +---------------------+
  |    TCP 或 UDP       | ← OpenVPN 可基于两者运行
  +---------------------+
  |         IP          |
  +---------------------+

2、OpenVPN 如何实现 TLS 握手?
OpenVPN 在 TCP 或 UDP 协议之上自主实现了 TLS 握手流程,不依赖 HTTP 协议。关键步骤:

  • 协议选择:
    • 若使用 proto tcp:TLS 握手基于 TCP 连接(类似 HTTPS)。
    • 若使用 proto udp:通过 DTLS(Datagram TLS) 在 UDP 上模拟 TLS 握手(需额外分片/重传机制)。
  • 握手过程(以 TCP 为例):
    Client                           Server
    | ----- ClientHello (支持的加密套件) ---> |
    | <---- ServerHello (选定加密套件) ----- |
    | <--------- ServerCertificate --------- | ← 服务端发送证书
    | <----- ServerKeyExchange (可选) ------ |
    | <-------- ServerHelloDone ----------- |
    | ------ ClientKeyExchange -----------> | ← 客户端生成会话密钥
    | -------- ChangeCipherSpec ----------> | ← 告知后续通信加密
    | ----------- Finished ---------------> | ← 握手完成验证
    | <-------- ChangeCipherSpec ---------- |
    | <----------- Finished -------------- |
    |                                      |
    | ------ 加密的应用数据通信 -----------> |
  • 证书作用:
    • 身份验证:服务端证书证明其身份(防止中间人攻击)。
    • 密钥协商:通过证书的公钥交换会话密钥(如 ECDHE-RSA)。

3、为什么 TLS 不依赖 HTTP?

  • 历史背景:TLS 前身是 SSL(Secure Sockets Layer),设计初衷是为任何基于 TCP 的协议提供加密,HTTP 只是最典型的应用。
  • 协议分层:TLS 属于会话层(OSI 第5层),而 HTTP 是应用层协议(OSI 第7层),两者是上下级关系,而非绑定关系。

4、OpenVPN 的特殊实现

  • 自定义协议封装:
    • OpenVPN 在 TLS 之上定义了自己的应用层协议,用于传输虚拟网络数据(如 IP 包或以太网帧)。TLS 仅负责加密这一自定义协议的载荷。
  • UDP 模式下的 DTLS:
    • 当 OpenVPN 使用 UDP 时,通过 DTLS(Datagram TLS) 适配 UDP 的无连接特性,解决丢包和乱序问题(类似 QUIC 协议)。

7.1.9.2、openvpn 自定义协议

1、OpenVPN 自定义协议的本质

设计目标

  • 透明传输虚拟网络数据:承载 IP 包或以太网帧(Layer 2/3)。
  • 适应 VPN 场景:支持动态密钥更新、压缩、多路复用等 VPN 特有功能。
  • 兼容 TLS/DTLS:将加密与协议逻辑解耦,TLS 仅作为安全层。

协议栈位置

  +-----------------------------+
  |   OpenVPN 自定义协议        | ← 定义数据封装、控制消息等
  +-----------------------------+
  |           TLS/DTLS          | ← 加密/解密(承载自定义协议)
  +-----------------------------+
  |         TCP 或 UDP          | ← 传输层基础
  +-----------------------------+

2、OpenVPN 协议的核心组成

(1) 数据封装格式

OpenVPN 协议的数据包分为两部分:

  1. Opcode(操作码):1字节,标识包类型(如数据包、控制消息等)。
    • 常见 Opcode:
      • 0x04:数据帧(Payload 为 IP 包或以太网帧)。
      • 0x05:密钥重新协商请求。
      • 0x07:压缩数据帧。
  2. Payload(载荷):实际传输的数据(如加密后的 IP 包)。

(2) 控制通道(Control Channel)

  • 功能:处理 TLS 握手、密钥交换、会话维护。
  • 协议细节
    • 使用固定格式的 OpenVPN 控制消息(如 P_CONTROL_HARD_RESET_CLIENT)。
    • 通过 TLS 加密,保证密钥交换安全。

(3) 数据通道(Data Channel)

  • 功能:传输加密后的用户数据(如 TCP/UDP 流量)。
  • 协议细节
    • 数据包通过 OpenVPN 协议封装后,再经 TLS 加密。
    • 支持分片(Fragmentation)和压缩(需配置)。

3、与 HTTP 等标准协议的对比

特性 OpenVPN 自定义协议 HTTP (RFC 标准)
设计目标 虚拟网络隧道 超文本传输
标准化 私有协议(无公开 RFC) 国际标准(RFC 2616/7230 等)
数据格式 二进制(Opcode + Payload) 文本(Header + Body)
扩展性 通过 Opcode 扩展 通过 Header 字段扩展
依赖的加密 可选 TLS/DTLS 必须依赖 TLS(HTTPS)

4、为什么 OpenVPN 不采用标准协议?

  • 灵活性需求:VPN 需要动态处理密钥更新、隧道维护等场景,标准协议(如 HTTP)无法满足。
  • 性能优化:二进制封装比文本协议(如 HTTP)更高效,适合传输 IP 包等二进制数据。
  • 安全性控制:自定义协议可以深度集成 TLS 和密钥轮换机制。

反例:标准协议在 VPN 中的局限性

  • IPSec/IKE:虽然标准化(RFC 4301),但配置复杂,难以适应所有场景。
  • WireGuard:同样使用自定义协议(但设计更简洁)。

5. OpenVPN 协议的实际数据示例

控制消息(TLS 握手阶段)

0000  01 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  ← Opcode: P_CONTROL_HARD_RESET_CLIENT
0010  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  ← Payload (空)

数据帧(加密后的 IP 包)

0000  04 00 00 2A 45 00 00 28  ← Opcode: 0x04 (数据帧), Payload 长度: 42 字节
0008  A1 23 00 00 80 11 00 00  ← 加密后的 IP 包数据 (示例)
0010  C0 A8 01 01 C0 A8 01 02
0018  00 35 D3 4F 00 14 00 00

7.2、openssh

7.2.1、本地端口转发

场景:我们需要远程使用 telnet 协议连接到某个被防火墙保护的内部网络中去。

local

SSH 的本地端口转发的格式如下:

ssh -L local_port:remote_host:remote_host_port sshserver

本地端口转发有如下常用选项:

  • -f:将 SSH 客户端放到后台运行,保持连接但不阻塞终端。
  • -N:只建立端口转发,而不打开远程 shell 或执行命令。单独使用 -N 会阻塞终端(除非与 -f 一起使用)。
  • -g:启用网关功能,允许非本地主机通过 SSH 客户端的本地端口发起连接,

我们的实现如下所示。 这个过程是在外部主机(172.18.253.127)上进行操作:

ssh -L 9527:172.18.253.58:23 -Nf 172.18.250.114

通过本地监听端口,使用 telnet 协议就可以访问到内部的数据库服务器了:

telnet 127.0.0.1 9527

7.2.2、远程端口转发

场景:外部主机不能访问内部主机,但内部主机可以访问外部主机,实现外部主机访问内部数据库。

remote

SSH 远程端口转发的格式如下:

ssh -R sshserver_port:remote_host:remotehost_port sshserver

我们的实现如下所示。 这个过程是在内部主机(172.18.250.114)上进行的操作:

ssh -R 9527:172.18.253.58:25 -Nf 172.18.253.127

此时我们切换到外部主机(172.18.253.127),使用 ss -tnlpu 查看一下当前监听的端口,就会发现开启了 9527 端口。如果此时我们使用 telnet 命令连接一下本地的端口,就能够像 SMTP 服务器发起 SMTP 请求了。

telnet 127.0.0.1 9527 

7.2.3、动态端口转发

场景:我们在防火墙内部想要访问放火墙外面的网站,但是防火墙给我们开放了很少的端口。如何不受限制访问外面。

dynamic

SSH 动态端口转发的格式如下:

# ssh server 指的就是我们在放火墙之外的代理服务器
# 1080 也可以是其他可用端口
ssh -D 1080 root@sshserver

我们为墙内的主机设置一下动态代理,执行这条命令的是172.18.253.127

ssh -D 1080 root@172.18.250.114

这时通过动态转发,可以将墙内主机发起的请求,转发到墙外主机,而由墙外主机去真正地发起请求。而在墙内发起的请求,需要由 Socket 代理(Socket Proxy)转发到 SSH 绑定的 1080 端口。我们以火狐浏览器为例,配置本地的网络访问代理。找到设置 => 高级 => 网络 => 代理,然后设置成如下的内容。

firefox_proxy

这样的话,Firefox 浏览器发起的请求都会转发到 1080 端口,然后通过 SSH 转发到真正的请求地址。

7.2.4、ssh 基于密钥登陆

ssh 连接大家经常用,也很好用,没有太在意,后来用到 CentOS8,openEuler24.03,KylinV10 时经常出问题,基于密钥无法认证。这里梳理一下 ssh 的认证,以及该注意的事项。

SSH 连接的主要流程:

  1. 连接建立与协商(握手阶段)
    服务器与客户端通过非对称密钥协商生成对称密钥,并商定加密、认证算法。
  2. 用户认证(认证阶段)
    客户端用非对称密钥或密码进行认证,服务器验证合法性。
  3. 数据传输(传输阶段)
    双方使用协商好的对称密钥加密数据,同时通过消息认证码(MAC)校验数据完整性。

1、握手阶段

Protocol 2

仅支持 SSH 协议版本 2(v2)。

  • SSH v1 存在严重漏洞(例如明文密钥交换)。

  • SSH v2 支持强加密(如 Diffie-Hellman、ECC)。

  • 握手阶段,双方协商协议版本。如果客户端试图使用 v1,会被拒绝连接。

  • 此配置决定了后续密钥交换算法 (KexAlgorithms)、加密算法 (Ciphers)、认证方式 (PubkeyAuthentication) 是否适用。

KexAlgorithms curve25519-sha256, diffie-hellman-group-exchange-sha256

指定密钥交换算法,生成对称密钥。

  • curve25519-sha256:使用椭圆曲线 Diffie-Hellman(ECDH)交换密钥,速度快且安全性高。

  • diffie-hellman-group-exchange-sha256:传统 Diffie-Hellman,但使用 SHA-256 防止弱散列攻击。

  • 握手阶段,双方协商使用的密钥交换算法。

  • 客户端提议 curve25519-sha256,服务器接受,双方利用此算法生成对称密钥 K。

  • 密钥交换算法直接影响后续的加密算法 (Ciphers) 和认证过程。

    • 对称加密密钥:密钥交换后,生成的对称密钥将用于数据加密。
    • 安全性影响:如果使用弱密钥交换算法(如 diffie-hellman-group1-sha1),可能被中间人攻击。

Ciphers aes128-ctr,aes256-gcm@openssh.com

  • 指定用于数据加密的对称算法,算法中是用对称密钥 K 去加密数据。
    • 依赖密钥交换算法 (KexAlgorithms) 提供的对称密钥 K。
    • 与完整性保护参数 (MACs) 协同防止数据篡改。

MACs hmac-sha2-256,hmac-sha2-512

指定消息认证码(MAC)算法,用于校验数据完整性。

  • hmac-sha2-256:SHA-256 HMAC 算法,验证数据是否被篡改。

  • hmac-sha2-512:更强的完整性保护。

  • 每个加密的数据包都附带 MAC 值,接收方根据密钥重新计算并对比,确认数据完整性。

    • Ciphers 配合使用,加密后的数据需附加 MAC。
    • 如果选择 aes256-gcm,其 GCM 模式内置完整性保护,MACs 参数可能被忽略。

2、用户认证阶段

PubkeyAuthentication yes

启用基于非对称密钥的公钥认证。

  • 客户端用私钥签名,服务器用对应公钥验证签名。
  1. 客户端发送公钥到服务器。
  2. 服务器验证公钥是否在 ~/.ssh/authorized_keys 中。
  3. 服务器发送随机数据,要求客户端用私钥签名。
  4. 服务器用公钥验证签名,确认用户身份。
  • 配合 PubkeyAcceptedKeyTypes 限制可用密钥类型(如 ssh-ed25519)。
  • 强烈建议禁用密码认证(PasswordAuthentication no),仅使用公钥认证。

PubkeyAcceptedKeyTypes ssh-ed25519,rsa-sha2-256

  • 指定允许的公钥类型。
    • ssh-ed25519:基于椭圆曲线 Ed25519 的公钥算法,速度快,安全性高。
    • rsa-sha2-256:RSA 公钥算法,使用 SHA-256 散列。
  • 服务器检查客户端提交的公钥类型是否符合配置,若不支持客户端的公钥类型,认证会失败。

StrictModes yes

  • 启用严格权限检查,防止权限过宽导致安全漏洞。
  • 用户登录前,服务器检查以下文件的权限:
    • ~/.ssh 必须为 700。
    • ~/.ssh/authorized_keys 必须为 600。

IgnoreRhosts yes

  • 禁用基于 ~/.rhosts/etc/hosts.equiv 文件的认证方式。
  • 需要配合 HostbasedAuthentication no 使用,因为 HostbasedAuthentication 依赖于 rhosts

HostbasedAuthentication no

  • 禁用主机认证(Host-based Authentication)。这种认证方式允许通过主机的私钥来认证用户,但需要客户端和服务器有匹配的主机密钥和可信设置。
  • IgnoreRhosts yes 联动。如果 HostbasedAuthentication 被禁用,~/.rhosts/etc/hosts.equiv 文件就没有作用。
  • 使用更安全的 PubkeyAuthentication 替代。

HostbasedAcceptedKeytypes

  • 定义允许用于主机认证(Host-based Authentication)的公钥算法。
  • 默认值通常包括较安全的算法,如 ssh-ed25519rsa-sha2-512
  • 认证阶段如果启用了 HostbasedAuthentication,服务器会验证这些算法生成的签名。
  • 如果 HostbasedAuthentication no,此参数将被忽略。

PermitUserEnvironment no

  • 禁止通过 .ssh/environment 文件设置环境变量。

  • 防止攻击者利用恶意环境变量干扰会话行为。

  • 登录阶段如果启用了用户环境配置,服务器会读取 ~/.ssh/environment 文件并应用其中的变量。

AllowTcpForwarding no

  • 禁止 SSH 会话中的 TCP 转发功能。
  • 这意味着客户端不能通过服务器建立额外的 TCP 通道。
  • 会话阶段防止用户在已登录的会话中转发流量到其他目标主机。
  • 可配合 AllowAgentForwarding no 使用,彻底防止用户滥用会话隧道功能。

AllowAgentForwarding no

  • 禁止客户端通过 SSH 代理转发认证请求。

GatewayPorts no

  • 禁止远程端口转发对外开放端口。
  • 即便开启了端口转发,转发的端口只能监听 localhost,不能对外提供服务。
  • 配合 AllowTcpForwarding no 可完全关闭所有转发功能。

PermitTunnel no

  • 禁止通过 SSH 创建网络隧道(Layer 2 或 Layer 3)。
  • 防止用户利用隧道访问内部网络资源。
  • 同样是限制 SSH 会话能力的参数,配合 AllowTcpForwarding no 提高安全性。

GSSAPIKexAlgorithms

  • 定义支持的 GSSAPI 密钥交换算法。

  • 用于 Kerberos 或其他基于 GSSAPI 的认证协议。

  • 密钥交换阶段,当客户端和服务器都支持 GSSAPI 时,可以协商使用这些算法进行密钥交换。

  • 如果服务器启用了 GSSAPIAuthentication yes,会使用此参数指定支持的 GSSAPI 算法。

  • 常见于需要 Kerberos 集成的环境。

CASignatureAlgorithms

  • 定义允许使用的 CA 签名算法。
  • SSH 可以通过 CA 签名的公钥进行认证,此参数限制支持的 CA 签名类型。
  • 认证阶段,验证客户端或服务器的公钥是否被允许的 CA 签名。
  • PubkeyAcceptedKeyTypesHostKeyAlgorithms 结合,用于限制公钥的类型和 CA 签名。

HostKeyAlgorithms

  • 定义服务器支持的主机密钥算法。
  • 主机密钥用于验证服务器的身份。
  • 认证阶段,客户端验证服务器提供的主机密钥是否匹配信任列表。
  • PubkeyAcceptedKeyTypes 相辅相成,分别限制客户端和服务器的密钥类型。
  • 建议仅使用现代算法(如 ssh-ed25519rsa-sha2-512)。

3、数据传输阶段

ClientAliveCountMax 0ClientAliveInterval

控制客户端的空闲连接行为。

  • ClientAliveInterval:服务器向客户端发送心跳包的间隔(秒)。
  • ClientAliveCountMax:客户端未响应心跳包后允许的最大次数。
    • 每隔 ClientAliveInterval 秒,服务器发送心跳包。如果客户端无响应,断开连接。
    • 设置为 0 表示立即断开连接。

注意:如果基于密钥认证出现所选的用户密钥未在远程主机上注册,一般是目录和文件权限问题,或者是两边算法不匹配,那就好好理解上面的参数含义。

7.3、frp

官方仓库:https://github.com/fatedier/frp

官方文档:https://gofrp.org/zh-cn/docs/

7.3.1、frp 原理

参考:https://juejin.cn/post/7249286574521581624
参考:https://zhuanlan.zhihu.com/p/403361038

frp 是什么?

frp 是一款高性能的反向代理应用,专注于内网穿透。它支持多种协议,包括 TCP、UDP、HTTP、HTTPS 等,并且具备 P2P 通信功能。使用 frp,您可以安全、便捷地将内网服务暴露到公网,通过拥有公网 IP 的节点进行中转。

为什么选择 frp?

通过在具有公网 IP 的节点上部署 frp 服务端,您可以轻松地将内网服务穿透到公网,并享受以下专业特性:

  • 多种协议支持:客户端服务端通信支持 TCP、QUIC、KCP 和 Websocket 等多种协议。
  • TCP 连接流式复用:在单个连接上承载多个请求,减少连接建立时间,降低请求延迟。
  • 代理组间的负载均衡。
  • 端口复用:多个服务可以通过同一个服务端端口暴露。
  • P2P 通信:流量不必经过服务器中转,充分利用带宽资源。
  • 客户端插件:提供多个原生支持的客户端插件,如静态文件查看、HTTPS/HTTP 协议转换、HTTP、SOCKS5 代理等,以便满足各种需求。
  • 服务端插件系统:高度可扩展的服务端插件系统,便于根据自身需求进行功能扩展。
  • 用户友好的 UI 页面:提供服务端和客户端的用户界面,使配置和监控变得更加方便。

官方示例够用了 https://gofrp.org/zh-cn/docs/examples/

通过简单配置 TCP 类型的代理,使用户能够访问内网服务器。

步骤

  1. 在具有公网 IP 的机器上部署 frps

    部署 frps 并编辑 frps.toml 文件。以下是简化的配置,其中设置了 frp 服务器用于接收客户端连接的端口:

    bindPort = 7000
  2. 在需要被访问的内网机器上部署 frpc

    部署 frpc 并编辑 frpc.toml 文件,假设 frps 所在服务器的公网 IP 地址为 x.x.x.x。以下是示例配置:

    serverAddr = "x.x.x.x"
    serverPort = 7000
    
    [[proxies]]
    name = "ssh"
    type = "tcp"
    localIP = "127.0.0.1"
    localPort = 22
    remotePort = 6000
    • localIPlocalPort 配置为需要从公网访问的内网服务的地址和端口。
    • remotePort 表示在 frp 服务端监听的端口,访问此端口的流量将被转发到本地服务的相应端口。
      • 注意,仅启动 frps 服务端是没有 remotePort 端口监听的,只有 frpc 客户端启动,连接 frps 服务端时,remotePort 端口才会送到服务端,服务端开始监听该端口。
  3. 启动 frps 和 frpc

    参考官网配置 systemd 服务 https://gofrp.org/zh-cn/docs/setup/systemd/

  4. 通过 SSH 访问内网机器

    使用以下命令通过 SSH 访问内网机器,假设用户名为 test:

    ssh -o Port=6000 test@x.x.x.x

    frp 将请求发送到 x.x.x.x:6000 的流量转发到内网机器的 22 端口。

官方仓库有完整的配置参考 https://github.com/fatedier/frp/tree/dev/conf ,当然官方文档也有详细说明,一般从完整配置中取一部分也就够用了。

7.3.2、frp 问题

我遇到了一个问题,就是使用 frp 转发 openvpn 的 tcp 端口时,出现如下问题:

Jun 11 12:29:42 k8s-pre-node01 openvpn: TCP connection established with [AF_INET]172.31.1.46:50990
Jun 11 12:29:42 k8s-pre-node01 openvpn: 172.31.1.46:50990 Connection reset, restarting [0]
Jun 11 12:29:42 k8s-pre-node01 openvpn: 172.31.1.46:50990 SIGUSR1[soft,connection-reset] received, client-instance restarting
Jun 11 12:29:42 k8s-pre-node01 openvpn: TCP connection established with [AF_INET]172.31.1.46:50992
Jun 11 12:29:42 k8s-pre-node01 openvpn: 172.31.1.46:50992 Connection reset, restarting [0]
Jun 11 12:29:42 k8s-pre-node01 openvpn: 172.31.1.46:50992 SIGUSR1[soft,connection-reset] received, client-instance restarting
Jun 11 12:29:42 k8s-pre-node01 openvpn: TCP connection established with [AF_INET]172.31.1.46:50994
Jun 11 12:29:42 k8s-pre-node01 openvpn: 172.31.1.46:50994 Connection reset, restarting [0]
Jun 11 12:29:42 k8s-pre-node01 openvpn: 172.31.1.46:50994 SIGUSR1[soft,connection-reset] received, client-instance restarting
Jun 11 12:29:42 k8s-pre-node01 openvpn: TCP connection established with [AF_INET]172.31.1.46:50996
Jun 11 12:29:42 k8s-pre-node01 openvpn: 172.31.1.46:50996 Connection reset, restarting [0]
Jun 11 12:29:42 k8s-pre-node01 openvpn: 172.31.1.46:50996 SIGUSR1[soft,connection-reset] received, client-instance restarting
Jun 11 12:29:42 k8s-pre-node01 openvpn: TCP connection established with [AF_INET]172.31.1.46:50998
Jun 11 12:29:42 k8s-pre-node01 openvpn: 172.31.1.46:50998 Connection reset, restarting [0]
Jun 11 12:29:42 k8s-pre-node01 openvpn: 172.31.1.46:50998 SIGUSR1[soft,connection-reset] received, client-instance restarting

通过跟 openvpn 开源社区的讨论,最后发现这是我自己搞的一个乌龙 https://github.com/OpenVPN/openvpn/issues/764

我的内网穿透都是使用华为云上面的 ELB 实现的,ELB 都有 health check,默认每隔 5s ELB TCP health check 就会对 frps 上绑定的客户端(frpc)端口(remotePort)发起请求探测,确认是否健康。正好对应上面的日志,看起来像是问题,实际上这是 openvpn 正常的响应日志。

另外,如果把 openvpn 配置成 udp 协议,就算 ELB UDP health check 不停请求,openvpn 也不会输出 UDP connection established, Connection reset, restarting 类似日志,所以可以使用 openvpn udp 协议,避免大量日志输出。当然也可以把 health check 关掉。

7.4、vnc

下面基于 x11 框架搭建 vnc 远程,编写脚本安装 x11vncserver

#!/bin/bash

# 配置变量
PASSWORD="123456"  # 登录账号的密码
LOG_FILE="/var/log/x11vnc_setup.log"  # 日志文件路径
PASSWD_FILE="/etc/x11vnc.pass"  # x11vnc 密码文件路径
SERVICE_FILE="/etc/systemd/system/x11vnc.service"  # 服务文件路径

# 初始化日志文件
init_log() {
    echo "初始化日志文件..." | tee -a "$LOG_FILE"
    touch "$LOG_FILE" || { echo "无法创建日志文件 $LOG_FILE"; exit 1; }
    echo "日志文件初始化完成。" | tee -a "$LOG_FILE"
}

# 安装 x11vnc
install_x11vnc() {
    echo "开始安装 x11vnc..." | tee -a "$LOG_FILE"
    echo "$PASSWORD" | sudo -S apt update && sudo apt install x11vnc -y >> "$LOG_FILE" 2>&1
    if [ $? -eq 0 ]; then
        echo "x11vnc 安装成功。" | tee -a "$LOG_FILE"
    else
        echo "x11vnc 安装失败,请检查日志文件 $LOG_FILE。" | tee -a "$LOG_FILE"
        exit 1
    fi
}

# 设置 x11vnc 密码
set_x11vnc_password() {
    echo "设置 x11vnc 密码..." | tee -a "$LOG_FILE"
    echo "$PASSWORD" | sudo -S x11vnc -storepasswd "$PASSWORD" "$PASSWD_FILE" >> "$LOG_FILE" 2>&1
    if [ $? -eq 0 ]; then
        echo "x11vnc 密码设置成功。" | tee -a "$LOG_FILE"
    else
        echo "x11vnc 密码设置失败,请检查日志文件 $LOG_FILE。" | tee -a "$LOG_FILE"
        exit 1
    fi
}

# 创建 x11vnc 服务文件
create_service_file() {
    echo "创建 x11vnc 服务文件..." | tee -a "$LOG_FILE"
    sudo tee "$SERVICE_FILE" > /dev/null <<EOF
[Unit]
Description=Remote desktop service (VNC)
Requires=display-manager.service
After=display-manager.service

[Service]
Type=simple
ExecStart=/usr/bin/x11vnc -auth guess -forever -rfbauth $PASSWD_FILE -rfbport 5900 -shared
ExecStop=/usr/bin/killall x11vnc

[Install]
WantedBy=multi-user.target
EOF
    if [ $? -eq 0 ]; then
        echo "服务文件创建成功。" | tee -a "$LOG_FILE"
    else
        echo "服务文件创建失败,请检查日志文件 $LOG_FILE。" | tee -a "$LOG_FILE"
        exit 1
    fi
}

# 加载并启动服务
enable_and_start_service() {
    echo "加载服务配置..." | tee -a "$LOG_FILE"
    sudo systemctl daemon-reload >> "$LOG_FILE" 2>&1
    if [ $? -ne 0 ]; then
        echo "服务配置加载失败,请检查日志文件 $LOG_FILE。" | tee -a "$LOG_FILE"
        exit 1
    fi

    echo "启动 x11vnc 服务..." | tee -a "$LOG_FILE"
    sudo systemctl start x11vnc >> "$LOG_FILE" 2>&1
    if [ $? -eq 0 ]; then
        echo "x11vnc 服务启动成功。" | tee -a "$LOG_FILE"
    else
        echo "x11vnc 服务启动失败,请检查日志文件 $LOG_FILE。" | tee -a "$LOG_FILE"
        exit 1
    fi

    echo "设置 x11vnc 服务自启动..." | tee -a "$LOG_FILE"
    sudo systemctl enable x11vnc >> "$LOG_FILE" 2>&1
    if [ $? -eq 0 ]; then
        echo "x11vnc 服务自启动设置成功。" | tee -a "$LOG_FILE"
    else
        echo "x11vnc 服务自启动设置失败,请检查日志文件 $LOG_FILE。" | tee -a "$LOG_FILE"
        exit 1
    fi
}

# 主函数
main() {
    init_log
    install_x11vnc
    set_x11vnc_password
    create_service_file
    enable_and_start_service
    echo "x11vnc 服务配置完成!" | tee -a "$LOG_FILE"
}

# 执行主函数
main

将脚本保存为 setup_x11vnc.sh。

赋予执行权限:

chmod +x setup_x11vnc.sh

问题:x11vnc server 所在的服务器如果没有接显示器,则无法自适应分辨率,可能采用默认的分辨率,大概是 640x400,客户端连接时会花屏或者分辨 率过低无法使用。

解决:
先写个脚本 display-setup.sh,放在类似 /etc/lightdm/ 这个文件夹下,此处以 /etc/lightdm/display-setup.sh 为例:

#!/bin/bash
xrandr --fb 1920x1080 --rate 60 # 设置分辨率和刷新率
exit 0 # 可能要加上,防止上一步设置出错导致脚本异常退出,lightdm 起不来

然后修改 /etc/lightdm/lightdm.conf,找到 [Seat:*] 配置部分,把原来的 #display-setup-script= 改成 display-setup-script=/etc/lightdm/display-setup.sh,保存后执行 systemctl restart lightdm 重启 LightDM 即可生效,即使没有连接显示器也可以保证 VNC 远程时分辨率和刷新率正常

  • xrandr 是 X11 的一个工具,用于设置屏幕分辨率、刷新率等参数。

  • 你在 display-setup-script 中使用 xrandr --fb 1920x1080 --rate 60 强制将显示器的分辨率设置为 1920x1080,刷新率为 60Hz。

  • 这样,即使没有物理显示器,X11 也会使用你指定的分辨率,VNC 连接时就不会出现分辨率不正确或花屏的问题。

说明:下面的内容来自 deepseek 科普,没有实际验证,deepseek 目前还是个毛坯房,一定有错误的内容,阅读者需谨慎。

7.4.1、图形显示流程

1、开机阶段:从硬件初始化到驱动加载

1.1、BIOS/UEFI阶段

  • 硬件初始化

    • 主板固件(BIOS/UEFI)执行POST(Power-On Self-Test),初始化PCIe总线

    • 通过VGA BIOS或UEFI GOP( Graphics Output Protocol )初始化显卡基础功能

    • 典型调用链(x86架构):

    // EDK2 UEFI实现片段
    InitializeGraphicsOutput() {
        PCI_ENUMERATE();  // 枚举PCI设备
        for (dev in PCI_DEVICES) {
            if (IS_GPU(dev)) {
                GOP_PROTOCOL *Gop;
                dev->Gop->SetMode(dev->Gop, 0); // 设置显示模式
            }
        }
    }

1.2、内核启动阶段

  • 显卡驱动加载机制

    • 内核通过PCI ID匹配驱动(drivers/gpu/drm目录):
    // drivers/gpu/drm/drm_pci.c
    static const struct pci_device_id pciidlist[] = {
        {0x8086, 0x0126, PCI_ANY_ID, PCI_ANY_ID, 0, 0, INTEL_IVB_IDS(INTEL_IVB_GT1_IDS)}, // Intel
        {0x10de, 0x0a65, PCI_ANY_ID, PCI_ANY_ID, 0, 0, NV_TESLA_IDS}, // NVIDIA
        {...}
    };
    • 驱动加载决策树
    if (内核配置CONFIG_DRM_NOUVEAU=y/m) && (GPU_PCI_ID == NVIDIA)
       加载nouveau驱动
    else if (内核配置CONFIG_DRM_I915=y/m) && (GPU_PCI_ID == Intel)
       加载i915驱动
    else if (存在firmware加载需求)
       通过initramfs处理
  • 内核编译选项影响(以Intel i915驱动为例):

    选项 类型 影响
    CONFIG_DRM_I915 y 编译进内核,自动加载
    CONFIG_DRM_I915 m 作为模块,需要modprobe或initramfs加载
    CONFIG_DRM_I915_FORCE_PROBE string 强制匹配特定PCI ID

2、显示服务器架构对比

2.1、X11与Wayland协议栈差异

组件 X11架构 Wayland架构
输入处理 xf86-input-*驱动 → X Server libinput直接交给compositor
渲染路径 GLX/EGL → X Server → 驱动 EGL直接与驱动交互
协议开销 每次操作需X协议序列化 零拷贝共享内存
  • X11关键调用链

    // xorg-server/dix/dispatch.c
    ProcessClientRequests() {
      while ((rc = ReadRequest(client)) == Success) {
          switch (client->reqType) {
              case X_CreateWindow: /* 处理窗口创建 */
                  CreateWindow(...);
                  break;
          }
      }
    }
  • Wayland数据流

    Client → wl_surface.attach(buffer) → wl_surface.commit()
          ↓
    Compositor → drmModeAtomicCommit() (KMS直接提交)

2.2、显示管理器配置实例

  • LightDM选择X11/Wayland的决策

    # /etc/lightdm/lightdm.conf
    [Seat:*]
    display-setup-script=/usr/bin/setup_display.sh  # 可在此脚本中设置WAYLAND_DISPLAY
    xserver-command=X -core +extension GLX  # 强制X11参数

3、显卡驱动与内核交互

3.1、DRM/KMS核心机制

  • 模式设置流程

    1. 用户空间调用drmModeSetCrtc()

    2. 内核执行原子提交:

      // drivers/gpu/drm/drm_atomic.c
      int drm_atomic_commit(struct drm_atomic_state *state) {
       drm_atomic_helper_commit_modeset_enable(dev, state);
       drm_atomic_helper_wait_for_vblanks(dev, state);
      }
    3. 硬件寄存器编程(以Intel为例):

      // drivers/gpu/drm/i915/display/intel_display.c
      void intel_crtc_enable_pipe(const struct intel_crtc_state *crtc_state) {
       I915_WRITE(PIPECONF(crtc->pipe), PIPECONF_ENABLE);
      }

3.2、多驱动共存问题

  • NVIDIA专有驱动冲突处理

    # /etc/modprobe.d/nvidia.conf
    blacklist nouveau
    options nvidia-drm modeset=1  # 启用KMS支持

    内核日志会显示驱动加载顺序:

    [    2.345678] nvidia: loading out-of-tree module taints kernel
    [    2.456789] [drm] Initialized nvidia-drm 0.0.0 20160202

4、远程显示协议实现差异

4.1、VNC与RDP技术对比

特性 x11vnc xrdp (RDP)
数据编码 RAW/RFB PACKET_COMPR_TYPE_RDP6
帧率 依赖XDamage扩展 使用GFX H.264加速
输入延迟 通常>50ms 可优化至<20ms
  • x11vnc像素捕获关键路径

    // x11vnc/src/x11vnc.c
    void scan_for_updates(void) {
      XDamageSubtract(disp, damage, None, None);
      XGetImage(disp, root, 0, 0, width, height, AllPlanes, ZPixmap, &ximg);
    }

4.2、无显示器场景处理

  • 虚拟输出创建(DRM虚拟驱动)

    # 加载drm虚拟驱动
    modprobe dummy_hcd
    echo "options drm_kms_helper.edid_firmware=edid/1920x1080.bin" > /etc/modprobe.d/drm.conf

7.4.2、Initramfs 深度解析

1、Initramfs 的本质与存在意义

Initramfs(Initial RAM File System)是一个临时根文件系统,它在内核启动后、真实根文件系统挂载前被加载到内存中。其存在必要性源于以下几个核心问题:

2、为什么不能把所有驱动/模块编译进内核?

2.1、内核镜像体积限制

  • 空间效率问题

    • 将全部驱动编译进内核(=y)会导致内核镜像膨胀。例如:
    # 内核大小对比
    vmlinuz-5.15 (基础配置)      → 5MB
    vmlinuz-5.15 (全驱动内置)    → 50MB+
    • 嵌入式设备可能只有几MB的存储空间,无法容纳巨型内核

2.2、硬件多样性问题

  • PCI ID冲突示例

    // 内核中可能包含数百个显卡驱动匹配表
    static const struct pci_device_id amdgpu_pci_table[] = {
      {0x1002, 0x687F, PCI_ANY_ID, PCI_ANY_ID, 0, 0, CHIP_VEGA10},
      {0x1002, 0x69A0, PCI_ANY_ID, PCI_ANY_ID, 0, 0, CHIP_VEGA12},
      // 约200+个AMD GPU ID...
    };

    如果全部编译进内核,会导致:

    • 内存浪费(未使用的驱动常驻内存)
    • 潜在驱动冲突(多个驱动匹配同一硬件)

2.3、模块化设计优势

Linux采用动态加载机制

加载方式 内存占用 灵活性 适用场景
内置(=y) 永久占用 关键驱动(如ext4文件系统)
模块(=m) 按需占用 非必要驱动(如显卡、WiFi)
Initramfs预加载 临时占用 折中 启动必需但非内核内置的驱动

3、Initramfs 的关键作用场景

3.1、加密根文件系统

  • 典型启动流程

    1. 内核加载基础驱动(如USB、NVMe控制器)
    2. Initramfs中的cryptsetup提示输入密码
    3. 解密后挂载真实根文件系统
    # Initramfs中的解密脚本示例
    /usr/share/initramfs-tools/scripts/local-top/cryptroot:
    if [ "$CRYPTROOT" = "yes" ]; then
      cryptsetup open /dev/sda5 root_crypt
    fi

3.2、特殊硬件初始化

  • NVIDIA GPU固件加载

    # Initramfs包含的固件文件
    /lib/firmware/nvidia/ga102/acr/ucode_ahesasc.bin
    /lib/firmware/nvidia/ga102/acr/ucode_asb.bin

    这些固件必须在驱动加载前就位,但:

    • 内核本身不包含第三方二进制固件(GPL兼容性问题)
    • 固件体积较大(单个GPU固件可达2MB)

3.3、多阶段驱动加载

  • 依赖关系解决

    [    1.234567] scsi 2:0:0:0: Direct-Access     Samsung  SSD 860 EVO 1TB  
    [    1.345678] sd 2:0:0:0: [sda] 1953525168 512-byte logical blocks
    [    1.456789] xhci_hcd 0000:00:14.0: xHCI Host Controller

    如果SCSI控制器和USB控制器驱动都是模块:

    • 内核需要先加载USB驱动才能读取U盘上的SCSI驱动
    • Initramfs提前打包这两个驱动解决"鸡生蛋"问题

4、Initramfs 与内核编译的关联

4.1、内核配置选项影响

关键配置参数:

# General setup → Initramfs
CONFIG_BLK_DEV_INITRD=y          # 启用Initramfs支持
CONFIG_INITRAMFS_SOURCE=""       # 空表示不内置Initramfs
CONFIG_RD_GZIP=y                 # 支持gzip压缩的Initramfs

4.2、构建流程对比

方法 优点 缺点
内核内置Initramfs 无需额外文件 修改需重新编译内核
外部Initramfs文件 可独立更新 需要bootloader加载

外部Initramfs典型生成命令:

mkinitramfs -o /boot/initrd.img-$(uname -r) $(uname -r)

4.3、模块选择机制

Initramfs工具根据/etc/initramfs-tools/modules决定包含哪些模块:

# 必须包含的模块
vfio
vfio_iommu_type1
nvidia-drm

这些模块会被depmod分析依赖关系后打包进Initramfs

5、Initramfs 在内核中的处理

5.1、内核启动流程

// init/main.c
void __init start_kernel(void) {
    ...
    rest_init(); → kernel_init() → prepare_namespace()
}

关键调用链:

  1. unpack_to_rootfs() 解压Initramfs
  2. execute_command 执行/init(Initramfs中的第一个进程)

5.2、Initramfs内存布局

+-------------------+ 
| 内核镜像          | 
+-------------------+ 
| Initramfs CPIO    | ← 由bootloader加载到内存指定地址
+-------------------+ 
| 页缓存/进程数据   |
+-------------------+

通过__initramfs_start__initramfs_size符号定位(见arch/x86/kernel/setup.c

5.3、NVIDIA驱动故障排查

如果未正确包含NVIDIA模块到Initramfs:

[    3.456789] nvidia: module verification failed: signature and/or required key missing
[    3.567890] nvidia-nvlink: Nvlink Core is being initialized, major device number 511
[    3.678901] nvidia 0000:01:00.0: vgaarb: changed VGA decodes

修复方法:

echo "nvidia nvidia-drm nvidia-modeset" >> /etc/initramfs-tools/modules
update-initramfs -u
标签云