1. dify-sandbox简介
dify-Sandbox这个系统具有让不可信的代码得以运行的功能,且能保障运行环境的安全性。其设计初衷是适应多租户的场景,意味着众多用户都有提交需执行代码的权限。而代码的执行处于沙盒这样一种特定环境中,在此环境里,对代码所能获取的资源以及能够进行的系统调用都施加了限制,以确保安全性和稳定性。
2. 代码实现
以tag 2.0.4为例,采用golang编写
git clong https://github.com/langgenius/dify-sandbox.git
git checkout 2.0.4
2.1 主函数
dify-sandbox本身是一个golang gin框架的api server服务
package main
import "github.com/langgenius/dify-sandbox/internal/server"
func main() {
// 主函数
server.Run()
}
package server
import (
"fmt"
"time"
"github.com/gin-gonic/gin"
"github.com/langgenius/dify-sandbox/internal/controller"
"github.com/langgenius/dify-sandbox/internal/core/runner/python"
"github.com/langgenius/dify-sandbox/internal/static"
"github.com/langgenius/dify-sandbox/internal/utils/log"
)
func initConfig() {
// 从config.yaml配置文件中初始化配置
err := static.InitConfig("conf/config.yaml")
if err != nil {
log.Panic("failed to init config: %v", err)
}
log.Info("config init success")
// 从dependencies/python-requirements.txt文件中读取需要安装的python依赖
err = static.SetupRunnerDependencies()
if err != nil {
log.Error("failed to setup runner dependencies: %v", err)
}
log.Info("runner dependencies init success")
}
func initServer() {
// 获取全局的dify-sandbox配置,是个单例
config := static.GetDifySandboxGlobalConfigurations()
if !config.App.Debug {
gin.SetMode(gin.ReleaseMode)
}
// 获取gin Engine实例
r := gin.Default()
// 设置middleware和路由
controller.Setup(r)
// 启动gin server
r.Run(fmt.Sprintf(":%d", config.App.Port))
}
func initDependencies() {
log.Info("installing python dependencies...")
// 获取需要安装的python依赖
dependenices := static.GetRunnerDependencies()
// 使用exec.Command安装python依赖
err := python.InstallDependencies(dependenices.PythonRequirements)
if err != nil {
log.Panic("failed to install python dependencies: %v", err)
}
log.Info("python dependencies installed")
log.Info("initializing python dependencies sandbox...")
// 初始化python代码执行模块的环境
err = python.PreparePythonDependenciesEnv()
if err != nil {
log.Panic("failed to initialize python dependencies sandbox: %v", err)
}
log.Info("python dependencies sandbox initialized")
// 每隔30分钟更新一次sandbox的python依赖安装
go func() {
ticker := time.NewTicker(30 * time.Minute)
for range ticker.C {
log.Info("updating python dependencies...")
err := python.InstallDependencies(dependenices.PythonRequirements)
if err != nil {
log.Error("failed to update python dependencies: %v", err)
}
log.Info("python dependencies updated")
}
}()
}
func Run() {
// 初始化配置
initConfig()
// 初始化依赖和sandbox环境
initDependencies()
// 初始化gin server
initServer()
}
2.2 GetDifySandboxGlobalConfigurations
获取全局配置的函数
// 声明一个全局配置的变量difySandboxGlobalConfigurations
var difySandboxGlobalConfigurations types.DifySandboxGlobalConfigurations
func InitConfig(path string) error {
difySandboxGlobalConfigurations = types.DifySandboxGlobalConfigurations{}
// read config file
// 默认从conf/config.yaml读取配置
configFile, err := os.Open(path)
if err != nil {
return err
}
defer configFile.Close()
// parse config file
decoder := yaml.NewDecoder(configFile)
err = decoder.Decode(&difySandboxGlobalConfigurations)
if err != nil {
return err
}
// 支持环境变量覆盖配置
debug, err := strconv.ParseBool(os.Getenv("DEBUG"))
if err == nil {
difySandboxGlobalConfigurations.App.Debug = debug
}
max_workers := os.Getenv("MAX_WORKERS")
if max_workers != "" {
difySandboxGlobalConfigurations.MaxWorkers, _ = strconv.Atoi(max_workers)
}
max_requests := os.Getenv("MAX_REQUESTS")
if max_requests != "" {
difySandboxGlobalConfigurations.MaxRequests, _ = strconv.Atoi(max_requests)
}
port := os.Getenv("SANDBOX_PORT")
if port != "" {
difySandboxGlobalConfigurations.App.Port, _ = strconv.Atoi(port)
}
timeout := os.Getenv("WORKER_TIMEOUT")
if timeout != "" {
difySandboxGlobalConfigurations.WorkerTimeout, _ = strconv.Atoi(timeout)
}
api_key := os.Getenv("API_KEY")
if api_key != "" {
difySandboxGlobalConfigurations.App.Key = api_key
}
python_path := os.Getenv("PYTHON_PATH")
if python_path != "" {
difySandboxGlobalConfigurations.PythonPath = python_path
}
if difySandboxGlobalConfigurations.PythonPath == "" {
difySandboxGlobalConfigurations.PythonPath = "/usr/local/bin/python3"
}
python_lib_path := os.Getenv("PYTHON_LIB_PATH")
if python_lib_path != "" {
difySandboxGlobalConfigurations.PythonLibPaths = strings.Split(python_lib_path, ",")
}
// PythonLibPaths如果为空,设置默认值,从常量DEFAULT_PYTHON_LIB_REQUIREMENTS读取
if len(difySandboxGlobalConfigurations.PythonLibPaths) == 0 {
difySandboxGlobalConfigurations.PythonLibPaths = DEFAULT_PYTHON_LIB_REQUIREMENTS
}
nodejs_path := os.Getenv("NODEJS_PATH")
if nodejs_path != "" {
difySandboxGlobalConfigurations.NodejsPath = nodejs_path
}
if difySandboxGlobalConfigurations.NodejsPath == "" {
difySandboxGlobalConfigurations.NodejsPath = "/usr/local/bin/node"
}
enable_network := os.Getenv("ENABLE_NETWORK")
if enable_network != "" {
difySandboxGlobalConfigurations.EnableNetwork, _ = strconv.ParseBool(enable_network)
}
if difySandboxGlobalConfigurations.EnableNetwork {
log.Info("network has been enabled")
socks5_proxy := os.Getenv("SOCKS5_PROXY")
if socks5_proxy != "" {
difySandboxGlobalConfigurations.Proxy.Socks5 = socks5_proxy
}
if difySandboxGlobalConfigurations.Proxy.Socks5 != "" {
log.Info("using socks5 proxy: %s", difySandboxGlobalConfigurations.Proxy.Socks5)
}
https_proxy := os.Getenv("HTTPS_PROXY")
if https_proxy != "" {
difySandboxGlobalConfigurations.Proxy.Https = https_proxy
}
if difySandboxGlobalConfigurations.Proxy.Https != "" {
log.Info("using https proxy: %s", difySandboxGlobalConfigurations.Proxy.Https)
}
http_proxy := os.Getenv("HTTP_PROXY")
if http_proxy != "" {
difySandboxGlobalConfigurations.Proxy.Http = http_proxy
}
if difySandboxGlobalConfigurations.Proxy.Http != "" {
log.Info("using http proxy: %s", difySandboxGlobalConfigurations.Proxy.Http)
}
}
return nil
}
// avoid global modification, use value copy instead
// 获取全局配置
func GetDifySandboxGlobalConfigurations() types.DifySandboxGlobalConfigurations {
return difySandboxGlobalConfigurations
}
// 常量DEFAULT_PYTHON_LIB_REQUIREMENTS的定义
var DEFAULT_PYTHON_LIB_REQUIREMENTS = []string{
"/usr/local/lib/python3.10",
"/usr/lib/python3.10",
"/usr/lib/python3",
"/usr/lib/x86_64-linux-gnu/libssl.so.3",
"/usr/lib/x86_64-linux-gnu/libcrypto.so.3",
"/etc/ssl/certs/ca-certificates.crt",
"/etc/nsswitch.conf",
"/etc/hosts",
"/etc/resolv.conf",
"/run/systemd/resolve/stub-resolv.conf",
"/run/resolvconf/resolv.conf",
}
2.3 PreparePythonDependenciesEnv
初始化python代码执行模块的环境
// 在Go 1.16中embed是新加入的包,通过go:embed指令在编译阶段将静态资源文件打包进程序并提供访问能力
//go:embed env.sh
var env_script string
func PreparePythonDependenciesEnv() error {
// 获取全局配置
config := static.GetDifySandboxGlobalConfigurations()
// 初始化TempDirRunner实例
runner := runner.TempDirRunner{}
// 调用WithTempDir方法创建临时目录:
// 1.创建/tmp/sandbox-xxx目录
// 2.`cp -r`WithTempDir函数中的paths到/tmp/sandbox-xxx目录下
// 3.Chdir切换工作目录到/tmp/sandbox-xxx
// 4.调用WithTempDir函数中的closures回调函数
err := runner.WithTempDir("/", []string{}, func(root_path string) error {
// 执行回调函数中的逻辑
// 1.将env.sh文件写入/tmp/sandbox-xxx目录下
err := os.WriteFile(path.Join(root_path, "env.sh"), []byte(env_script), 0755)
if err != nil {
return err
}
// 2.遍历config.PythonLibPaths,默认读取的是常量DEFAULT_PYTHON_LIB_REQUIREMENTS,执行env.sh脚本
for _, lib_path := range config.PythonLibPaths {
// check if the lib path is available
if _, err := os.Stat(lib_path); err != nil {
log.Warn("python lib path %s is not available", lib_path)
continue
}
// 常量LIB_PATH="/var/sandbox/sandbox-python"
// 执行env.sh复制文件逻辑
exec_cmd := exec.Command(
"bash",
path.Join(root_path, "env.sh"),
lib_path,
LIB_PATH,
)
exec_cmd.Stderr = os.Stderr
if err := exec_cmd.Run(); err != nil {
return err
}
}
// 删除/tmp/sandbox-xxx目录
os.RemoveAll(root_path)
os.Remove(root_path)
return nil
})
return err
}
#!/bin/bash
# Check if the correct number of arguments are provided
if [ "$#" -ne 2 ]; then
echo "Usage: $0 <src> <dest>"
exit 1
fi
src="$1"
dest="$2"
# Function to copy and link files
copy_and_link() {
local src_file="$1"
local dest_file="$2"
if [ -L "$src_file" ]; then
# If src_file is a symbolic link, copy it without changing permissions
cp -P "$src_file" "$dest_file"
elif [ -b "$src_file" ] || [ -c "$src_file" ]; then
# If src_file is a device file, copy it and change permissions
cp "$src_file" "$dest_file"
chmod 444 "$dest_file"
else
# Otherwise, create a hard link and change the permissions to read-only
ln -f "$src_file" "$dest_file" 2>/dev/null || { cp "$src_file" "$dest_file" && chmod 444 "$dest_file"; }
fi
}
# Check if src is a file or directory
if [ -f "$src" ]; then
# src is a file, create hard link directly in dest
mkdir -p "$(dirname "$dest/$src")"
copy_and_link "$src" "$dest/$src"
elif [ -d "$src" ]; then
# src is a directory, process as before
mkdir -p "$dest/$src"
# Find all files in the source directory
find "$src" -type f | while read -r file; do
# Get the relative path of the file
rel_path="${file#$src/}"
# Get the directory of the relative path
rel_dir=$(dirname "$rel_path")
# Create the same directory structure in the destination
mkdir -p "$dest/$src/$rel_dir"
# Copy and link the file
copy_and_link "$file" "$dest/$src/$rel_path"
done
else
echo "Error: $src is neither a file nor a directory"
exit 1
fi
env.sh脚本逻辑: 1.检查是否提供了正确数量的参数(源路径 和目标路径 ) 2.定义 copy_and_link 函数: - 如果源文件是符号链接,则复制它且保留权限。 - 如果源文件是设备文件,则复制它并设置只读权限。 - 否则创建硬链接,并设置只读权限;若失败则复制文件并设为只读。 3.判断源路径 : - 若是文件,则在目标目录下创建硬链接。 - 若是目录,则递归处理所有文件,创建相应的目录结构,并对每个文件调用 copy_and_link 函数。 - 若 既不是文件也不是目录,则输出错误信息并退出。
2.4 gin middleware和路由
dify-sandbox中的gin api middleware和路由定义
func Setup(eng *gin.Engine) {
// 校验X-Api-Key是否等于config.App.Key,默认值为dify-sandbox
eng.Use(middleware.Auth())
// 在sandbox中运行代码,支持python、nodejs语言
eng.POST(
"/v1/sandbox/run",
middleware.MaxRequest(static.GetDifySandboxGlobalConfigurations().MaxRequests),
middleware.MaxWorker(static.GetDifySandboxGlobalConfigurations().MaxWorkers),
RunSandboxController,
)
// 获取python依赖包列表,默认包含jinja2、httpx、requests
eng.GET(
"/v1/sandbox/dependencies",
GetDependencies,
)
// 直接触发PreparePythonDependenciesEnv
eng.POST(
"/v1/sandbox/dependencies/update",
UpdateDependencies,
)
}
2.4.1 RunSandboxController
/v1/sandbox/run
在sandbox中运行代码的处理逻辑
func RunSandboxController(c *gin.Context) {
BindRequest(c, func(req struct {
Language string `json:"language" form:"language" binding:"required"`
Code string `json:"code" form:"code" binding:"required"`
Preload string `json:"preload" form:"preload"`
EnableNetwork bool `json:"enable_network" form:"enable_network"`
}) {
switch req.Language {
// python语言
case "python3":
c.JSON(200, service.RunPython3Code(req.Code, req.Preload, &runner_types.RunnerOptions{
EnableNetwork: req.EnableNetwork,
}))
// nodejs语言
case "nodejs":
c.JSON(200, service.RunNodeJsCode(req.Code, req.Preload, &runner_types.RunnerOptions{
EnableNetwork: req.EnableNetwork,
}))
default:
c.JSON(400, types.ErrorResponse(-400, "unsupported language"))
}
})
}
针对python语言的处理逻辑
func RunPython3Code(code string, preload string, options *runner_types.RunnerOptions) *types.DifySandboxResponse {
// 校验enable_network参数是否一致
if err := checkOptions(options); err != nil {
return types.ErrorResponse(-400, err.Error())
}
// 获取全局配置中的worker_timeout参数,默认值为5s
timeout := time.Duration(
static.GetDifySandboxGlobalConfigurations().WorkerTimeout * int(time.Second),
)
// 创建python.PythonRunner对象,并调用其Run方法执行python代码
runner := python.PythonRunner{}
stdout, stderr, done, err := runner.Run(
code, timeout, nil, preload, options,
)
if err != nil {
return types.ErrorResponse(-500, err.Error())
}
stdout_str := ""
stderr_str := ""
defer close(done)
defer close(stdout)
defer close(stderr)
for {
select {
// 如果done通道关闭,则返回成功响应
case <-done:
return types.SuccessResponse(&RunCodeResponse{
Stdout: stdout_str,
Stderr: stderr_str,
})
// 从stdout和stderr通道中读取数据,并拼接到stdout_str和stderr_str中
case out := <-stdout:
stdout_str += string(out)
case err := <-stderr:
stderr_str += string(err)
}
}
}
func (p *PythonRunner) Run(
code string,
timeout time.Duration,
stdin []byte,
preload string,
options *types.RunnerOptions,
) (chan []byte, chan []byte, chan bool, error) {
// 获取全局配置
configuration := static.GetDifySandboxGlobalConfigurations()
// 初始化代码执行环境
// 1.环境检查与清理:确保必要的库可用/var/sandbox/sandbox-python/python.so,若不可用则释放相关资源。
// 2.临时脚本准备:
// 生成一个随机的 UUID 并进行格式调整以适合作为临时 Python 脚本的文件名。
// 根据提供的模板 sandbox_fs 创建一个脚本字符串,其中包含:用户 ID (static.SANDBOX_USER_UID)。组 ID (static.SANDBOX_GROUP_ID)。网络启用标志(根据 options.EnableNetwork)。预加载的代码或配置 (preload)。
// 3.加密与编码:生成一个 512 位的随机密钥。使用此密钥对输入的 Python 代码 code 进行异或加密。将加密后的代码和密钥分别使用 Base64 编码。
// 4.文件写入:将编码后的加密代码嵌入到脚本字符串中。在指定路径下创建一个临时目录,并将最终的脚本字符串写入到一个Python文件中。
// 5.返回结果:返回临时Python脚本的路径、Base64编码后的密钥以及可能发生的错误。
untrusted_code_path, key, err := p.InitializeEnvironment(code, preload, options)
if err != nil {
return nil, nil, nil, err
}
// capture the output
output_handler := runner.NewOutputCaptureRunner()
output_handler.SetTimeout(timeout)
err = p.WithTempDir(LIB_PATH, REQUIRED_FS, func(root_path string) error {
// 确保执行完python脚本后清除临时目录和文件
output_handler.SetAfterExitHook(func() {
os.RemoveAll(root_path)
os.Remove(root_path)
})
// 创建进程执行编码后的python代码
cmd := exec.Command(
configuration.PythonPath,
untrusted_code_path,
LIB_PATH,
key,
)
// 清空了继承的环境变量
cmd.Env = []string{}
if configuration.Proxy.Socks5 != "" {
cmd.Env = append(cmd.Env, fmt.Sprintf("HTTPS_PROXY=%s", configuration.Proxy.Socks5))
cmd.Env = append(cmd.Env, fmt.Sprintf("HTTP_PROXY=%s", configuration.Proxy.Socks5))
} else if configuration.Proxy.Https != "" || configuration.Proxy.Http != "" {
if configuration.Proxy.Https != "" {
cmd.Env = append(cmd.Env, fmt.Sprintf("HTTPS_PROXY=%s", configuration.Proxy.Https))
}
if configuration.Proxy.Http != "" {
cmd.Env = append(cmd.Env, fmt.Sprintf("HTTP_PROXY=%s", configuration.Proxy.Http))
}
}
// CaptureOutput函数实现了对外部命令执行的监控,包括超时处理、输出捕获、错误处理等功能,确保了命令执行的安全性和可控性
err = output_handler.CaptureOutput(cmd)
if err != nil {
return err
}
return nil
})
if err != nil {
return nil, nil, nil, err
}
return output_handler.GetStdout(), output_handler.GetStderr(), output_handler.GetDone(), nil
}
2.4.2 python代码模板文件
在sandbox运行python代码,利用了这个python脚本模板prescript.py
,把真正待执行的代码包含在其中。这个python脚本模板在开头部分加入了钩子处理、Seccomp校验系统调用权限。
import ctypes
import os
import sys
import traceback
# setup sys.excepthook
# 设置了一个自定义的异常处理钩子(excepthook),当Python程序中未被捕获的异常发生时,这个钩子会被调用。
def excepthook(type, value, tb):
sys.stderr.write("".join(traceback.format_exception(type, value, tb)))
sys.stderr.flush()
sys.exit(-1)
sys.excepthook = excepthook
lib = ctypes.CDLL("./var/sandbox/sandbox-python/python.so")
lib.DifySeccomp.argtypes = [ctypes.c_uint32, ctypes.c_uint32, ctypes.c_bool]
lib.DifySeccomp.restype = None
# get running path
# 得到的是LIB_PATH=/var/sandbox/sandbox-python
running_path = sys.argv[1]
if not running_path:
exit(-1)
# get decrypt key
# 获取用于解密的key
key = sys.argv[2]
if not key:
exit(-1)
from base64 import b64decode
# key先进行base64解码
key = b64decode(key)
# 工作目录切换到了/var/sandbox/sandbox-python
os.chdir(running_path)
{{preload}}
# 触发python类语言的Seccomp实现,用于限制系统调用(nodejs语言也是类似的逻辑)
# uid=65537,sandbox用户
# gid=1000,sandbox用户组
# enable_network=true,允许网络访问
lib.DifySeccomp({{uid}}, {{gid}}, {{enable_network}})
# 待执行的代码base64解码
code = b64decode("{{code}}")
# 解密的逻辑:对一个数异或2次得到原数
def decrypt(code, key):
key_len = len(key)
code_len = len(code)
code = bytearray(code)
for i in range(code_len):
code[i] = code[i] ^ key[i % key_len]
return bytes(code)
code = decrypt(code, key)
exec(code)
2.4.3 python语言DifySeccomp
限制python语言的系统调用,核心逻辑实现:
func InitSeccomp(uid int, gid int, enable_network bool) error {
err := syscall.Chroot(".")
if err != nil {
return err
}
err = syscall.Chdir("/")
if err != nil {
return err
}
// 调用系统调用SYS_PRCTL,执行PR_SET_NO_NEW_PRIVS操作,参数为(0x26, 1, 0, 0, 0, 0)。此操作用于限制进程获取新的特权。如果调用失败,函数返回错误码e,否则返回nil。
lib.SetNoNewPrivs()
allowed_syscalls := []int{}
allowed_not_kill_syscalls := []int{}
allowed_syscall := os.Getenv("ALLOWED_SYSCALLS")
if allowed_syscall != "" {
nums := strings.Split(allowed_syscall, ",")
for num := range nums {
syscall, err := strconv.Atoi(nums[num])
if err != nil {
continue
}
allowed_syscalls = append(allowed_syscalls, syscall)
}
} else {
allowed_syscalls = append(allowed_syscalls, python_syscall.ALLOW_SYSCALLS...)
if enable_network {
allowed_syscalls = append(allowed_syscalls, python_syscall.ALLOW_NETWORK_SYSCALLS...)
}
}
err = lib.Seccomp(allowed_syscalls, allowed_not_kill_syscalls)
if err != nil {
return err
}
// setuid
err = syscall.Setuid(uid)
if err != nil {
return err
}
// setgid
err = syscall.Setgid(gid)
if err != nil {
return err
}
return nil
}
初始化Seccomp,设置特定的系统调用白名单并更改进程的用户和组ID。功能点包括:
- 使用Chroot和Chdir系统调用更改根目录和当前工作目录。
- 设置禁止提升权限。
- 从环境变量或预定义列表中获取允许的系统调用,并存储到allowed_syscalls列表中。
- 如果启用了网络,则添加额外的允许网络系统调用。
- 使用allowed_syscalls和空的allowed_not_kill_syscalls调用lib的Seccomp函数。
- 设置进程的用户ID(setuid)和组ID(setgid)为传入的uid和gid。
2.4.4 nodejs语言DifySeccomp
限制nodejs语言的系统调用,跟上述限制python语言的系统调用处理逻辑一致
func InitSeccomp(uid int, gid int, enable_network bool) error {
err := syscall.Chroot(".")
if err != nil {
return err
}
err = syscall.Chdir("/")
if err != nil {
return err
}
lib.SetNoNewPrivs()
allowed_syscalls := []int{}
allowed_not_kill_syscalls := []int{}
allowed_syscall := os.Getenv("ALLOWED_SYSCALLS")
if allowed_syscall != "" {
nums := strings.Split(allowed_syscall, ",")
for num := range nums {
syscall, err := strconv.Atoi(nums[num])
if err != nil {
continue
}
allowed_syscalls = append(allowed_syscalls, syscall)
}
} else {
allowed_syscalls = append(allowed_syscalls, nodejs_syscall.ALLOW_SYSCALLS...)
allowed_not_kill_syscalls = append(allowed_not_kill_syscalls, nodejs_syscall.ALLOW_ERROR_SYSCALLS...)
if enable_network {
allowed_syscalls = append(allowed_syscalls, nodejs_syscall.ALLOW_NETWORK_SYSCALLS...)
}
}
err = lib.Seccomp(allowed_syscalls, allowed_not_kill_syscalls)
if err != nil {
return err
}
// setuid
err = syscall.Setuid(uid)
if err != nil {
return err
}
// setgid
err = syscall.Setgid(gid)
if err != nil {
return err
}
return nil
}
自定义系统调用
最新版的dify-sandbox允许传入ALLOWED_SYSCALLS的变量,因为执行sandbox中的脚本并没有继承父进程的环境变量,代码中使用了cmd.Env = []string{}
,那个patch的逻辑是从环境变量或配置文件中获取,赋予给cmd.Env
if len(configuration.AllowedSyscalls) > 0 {
cmd.Env = append(cmd.Env, fmt.Sprintf("ALLOWED_SYSCALLS=%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(configuration.AllowedSyscalls)), ","), "[]")))
}
dify-sandbox支持自定义系统调用的配置文件如下:
# vim conf/config.yaml
app:
port: 8194
debug: True
key: dify-sandbox
max_workers: 4
max_requests: 50
worker_timeout: 5
python_path: /usr/local/bin/python3
enable_network: True # please make sure there is no network risk in your environment
allowed_syscalls:
- 0
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
- 14
- 15
- 16
- 17
- 18
- 19
- 20
- 21
- 22
- 23
- 24
- 25
- 26
- 27
- 28
- 29
- 30
- 31
- 32
- 33
- 34
- 35
- 36
- 37
- 38
- 39
proxy:
socks5: ''
http: ''
https: ''
「真诚赞赏,手留余香」
真诚赞赏,手留余香
使用微信扫描二维码完成支付