mirror of
https://github.com/cnk3x/xunlei.git
synced 2026-06-03 21:01:32 +08:00
fix: ...
This commit is contained in:
parent
99f0a5ffb0
commit
e7f206adfc
1
.gitignore
vendored
1
.gitignore
vendored
@ -11,3 +11,4 @@ home.repo
|
||||
testdata/
|
||||
/amd64/
|
||||
artifacts/
|
||||
/lib
|
||||
|
||||
7
.vscode/settings.json
vendored
7
.vscode/settings.json
vendored
@ -1,5 +1,6 @@
|
||||
{
|
||||
"go.toolsEnvVars": {
|
||||
"GOOS": "linux"
|
||||
}
|
||||
"go.toolsEnvVars": {
|
||||
"GOOS": "linux",
|
||||
"CGO_ENABLED": "0"
|
||||
}
|
||||
}
|
||||
|
||||
40
Dockerfile
40
Dockerfile
@ -1,18 +1,23 @@
|
||||
FROM ubuntu:jammy
|
||||
FROM debian:stable-slim
|
||||
ARG TARGETARCH
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt update
|
||||
RUN apt install --no-install-recommends -y ca-certificates tzdata curl xz-utils
|
||||
RUN apt install --no-install-recommends -y ca-certificates tzdata curl wget xz-utils
|
||||
|
||||
# ENV spk=/tmp/xl.spk spk_tmp=/tmp/xl-tmp rootfs=/rootfs
|
||||
# RUN mkdir -p ${spk_tmp} ${rootfs}/etc/ssl/certs
|
||||
# RUN wget -O ${spk} "https://down.sandai.net/nas/nasxunlei-DSM7-$([ "${TARGETARCH}" = "arm64" ] && echo x86_64 || echo armv8).spk"
|
||||
# RUN tar -xvOf ${spk} package.tgz | tar -xvJC ${spk_tmp} ui/index.cgi bin
|
||||
# RUN ldd ${spk_tmp}/ui/index.cgi ${spk_tmp}/bin/bin/xunlei-pan-cli* 2>/dev/null | grep '=>' | awk '{printf "cp %s /rootfs/lib/%s;\n", $3, $1}' | sh
|
||||
|
||||
ENV rootfs=/rootfs
|
||||
RUN mkdir -p ${rootfs}/etc/ssl/certs ${rootfs}/lib
|
||||
RUN find /usr/lib \( -name libdl.so.2 -o -name libgcc_s.so.1 -o -name libstdc++.so.6 \) -exec cp -Lr {} ${rootfs}/lib/ \;
|
||||
|
||||
ENV spk=/tmp/xl.spk spk_tmp=/tmp/xl-tmp rootfs=/rootfs
|
||||
RUN mkdir -p ${spk_tmp} ${rootfs}/etc/ssl/certs
|
||||
RUN curl -kLo ${spk} "https://down.sandai.net/nas/nasxunlei-DSM7-$([ "${TARGETARCH}" = "arm64" ] && echo x86_64 || echo armv8).spk"
|
||||
RUN tar -xvOf ${spk} package.tgz | tar -xvJC ${spk_tmp} ui/index.cgi bin
|
||||
RUN ldd ${spk_tmp}/ui/index.cgi ${spk_tmp}/bin/bin/* 2>/dev/null | grep -v 'not found' | awk '{print $3}' | sort | uniq | xargs -I '{}' cp -v '{}' ${rootfs}/lib
|
||||
RUN cp -Lr /usr/share/zoneinfo/Asia/Chongqing ${rootfs}/etc/localtime
|
||||
RUN echo "Asia/Chongqing" >${rootfs}/etc/timezone
|
||||
RUN cp -Lr --parents /etc/ssl/certs/ca-certificates.crt ${rootfs}
|
||||
RUN cp -Lr --parents /etc/ssl/certs/ca-certificates.crt ${rootfs}/
|
||||
|
||||
COPY artifacts/xlp-${TARGETARCH} /rootfs/xlp
|
||||
RUN chmod +x /rootfs/xlp
|
||||
@ -32,17 +37,16 @@ LABEL org.opencontainers.image.source=https://github.com/cnk3x/xunlei
|
||||
COPY --from=1 /rootfs /
|
||||
|
||||
ENV XL_DASHBOARD_PORT=2345 \
|
||||
XL_DASHBOARD_IP= \
|
||||
XL_DASHBOARD_USERNAME= \
|
||||
XL_DIR_DOWNLOAD=/xunlei/downloads \
|
||||
XL_PREVENT_UPDATE= \
|
||||
XL_SPK_URL= \
|
||||
XL_UID= \
|
||||
XL_GID= \
|
||||
XL_DEBUG=
|
||||
XL_DASHBOARD_IP= \
|
||||
XL_DASHBOARD_USERNAME= \
|
||||
XL_DIR_DOWNLOAD=/xunlei/downloads \
|
||||
XL_PREVENT_UPDATE= \
|
||||
XL_SPK_URL= \
|
||||
XL_UID= \
|
||||
XL_GID= \
|
||||
XL_DEBUG=
|
||||
|
||||
VOLUME [ "/xunlei/data", "/xunlei/var/packages/pan-xunlei-com" ]
|
||||
|
||||
EXPOSE 2345
|
||||
|
||||
CMD [ "/xlp", "-r", "/xunlei" ]
|
||||
CMD [ "/xlp" ]
|
||||
|
||||
11
Makefile
11
Makefile
@ -46,10 +46,13 @@ wsl::
|
||||
GOARCH=amd64 $(GoBuild) -v -o /usr/local/bin/xlp ./cmd/xlp
|
||||
|
||||
home:: amd64
|
||||
docker buildx build --push -t $(HOME_REPO)$(NAME):$(VERSION) .
|
||||
$(DBuildBase) --push -t $(HOME_REPO)$(NAME):$(VERSION) .
|
||||
|
||||
load:: amd64
|
||||
docker buildx build --load -t $(NAME):$(VERSION) .
|
||||
$(DBuildBase) --load --platform linux/amd64,linux/arm64 -t $(NAME):$(VERSION) .
|
||||
|
||||
ubuntu::
|
||||
$(DBuildBase) --load -t $(NAME)-ubuntu:$(VERSION) -f ubuntu.Dockerfile .
|
||||
ubuntu:: amd64
|
||||
$(DBuildBase) --load --platform linux/amd64,linux/arm64 -t $(NAME)-ubuntu:$(VERSION) -f ubuntu.Dockerfile .
|
||||
|
||||
debian:: amd64
|
||||
$(DBuildBase) --load --platform linux/amd64,linux/arm64 -t $(NAME)-debian:$(VERSION) -f debian.Dockerfile .
|
||||
|
||||
78
README.md
78
README.md
@ -55,22 +55,26 @@ XL_DASHBOARD_IP=
|
||||
XL_DASHBOARD_USERNAME=
|
||||
# 网页访问的密码
|
||||
XL_DASHBOARD_PASSWORD=
|
||||
# 下载保存文件夹,多个用冒号`:`隔开
|
||||
# 如果需要指定多个下载目录,手动指定XL_DIR_DOWNLOAD
|
||||
# 多个以冒号`:`隔开,在容器内,都必须以 /xunlei 开头,迅雷面板选择保存路径显示会去掉/xunlei前缀
|
||||
# 指定后可以在 volumes 中绑定宿主机实际目录
|
||||
# 迅雷云盘的缓存会使用第一个目录会缓存
|
||||
# /xunlei/后面可以用中文
|
||||
# 不设置默认一个目录 /xunlei/downloads
|
||||
XL_DIR_DOWNLOAD=/xunlei/downloads
|
||||
# 程序数据保存文件夹,存储了登录的账号,下载进度等信息
|
||||
# 程序数据保存文件夹,存储了登录的账号,下载进度等信息,容器内不要更改
|
||||
XL_DIR_DATA=/xunlei/data
|
||||
# CHROOT主目录,这个不要改
|
||||
XL_CHROOT=/xunlei
|
||||
# 阻止更新
|
||||
XL_PREVENT_UPDATE=true
|
||||
# SPK下载链接
|
||||
XL_SPK_URL=https://down.sandai.net/nas/nasxunlei-DSM7-(x86_64或者armv8).spk
|
||||
# 运行迅雷的用户ID
|
||||
XL_UID=
|
||||
XL_PREVENT_UPDATE=false
|
||||
# SPK下载链接, 可以使用 file:/// 访问本地文件, 真实使用路径会去掉 file://, 所以如果是绝对路径, 三个斜杠不能少
|
||||
XL_SPK=https://down.sandai.net/nas/nasxunlei-DSM7-(x86_64或者armv8).spk
|
||||
# 运行迅雷的用户ID, 默认0,即 root 账号
|
||||
# 推荐使用当前账号的UID和GID, 一般来说是 1000, 以免出现下载后普通账号无法处理文件的情况
|
||||
XL_UID=0
|
||||
# 运行迅雷的用户GID
|
||||
XL_GID=
|
||||
XL_GID=0
|
||||
# 是否开启调试日志
|
||||
XL_DEBUG=
|
||||
XL_DEBUG=false
|
||||
```
|
||||
|
||||
#### 在容器中运行
|
||||
@ -100,33 +104,33 @@ cnk3x/xunlei
|
||||
|
||||
```yaml
|
||||
services:
|
||||
xunlei:
|
||||
container_name: xunlei
|
||||
image: cnk3x/xunlei:latest
|
||||
restart: unless-stopped
|
||||
xunlei:
|
||||
container_name: xunlei
|
||||
image: cnk3x/xunlei:latest
|
||||
restart: unless-stopped
|
||||
|
||||
# 宿主机名,迅雷远程控制的名称与此相关,一般是 `群晖-${hostname}`
|
||||
hostname: my_storage
|
||||
# 必须, cap_add: [SYS_ADMIN] 和 privileged: true 二选一
|
||||
cap_add: [SYS_ADMIN]
|
||||
ports: [2345:2345] # 面板访问端口,如需更改,替换前面的2345即可
|
||||
environment:
|
||||
# 如果需要指定多个下载目录,手动指定XL_DIR_DOWNLOAD
|
||||
# 多个以冒号`:`隔开,都必须以 /xunlei 开头,迅雷面板选择保存路径显示会去掉/xunlei前缀
|
||||
# 指定后可以在 volumes 中绑定宿主机实际目录
|
||||
# 迅雷云盘的缓存会使用第一个目录会缓存
|
||||
# /xunlei/后面可以用中文
|
||||
# 不设置默认一个目录 /xunlei/downloads
|
||||
- XL_DIR_DOWNLOAD=/xunlei/downloads:/xunlei/movies:/xunlei/apps
|
||||
volumes:
|
||||
# 对应 XL_DIR_DOWNLOAD 指定的目录, 请替换冒号前面的路径为实际路径
|
||||
- 实际【下载】文件夹路径:/xunlei/downloads
|
||||
- 实际【电影】文件夹路径:/xunlei/movies
|
||||
- 实际【软件】文件夹路径:/xunlei/apps
|
||||
# 数据目录,必须,迅雷运行时,插件,升级,包括登录数据都在这
|
||||
- ./data:/xunlei/data
|
||||
# 可选,不配置每次启动都会从远程下载spk
|
||||
- ./cache:/xunlei/var/packages/pan-xunlei-com
|
||||
# 宿主机名,迅雷远程控制的名称与此相关,一般是 `群晖-${hostname}`
|
||||
hostname: my_storage
|
||||
# 必须, cap_add: [SYS_ADMIN] 和 privileged: true 二选一
|
||||
cap_add: [SYS_ADMIN]
|
||||
ports: [2345:2345] # 面板访问端口,如需更改,替换前面的2345即可
|
||||
environment:
|
||||
# 如果需要指定多个下载目录,手动指定XL_DIR_DOWNLOAD
|
||||
# 多个以冒号`:`隔开,都必须以 /xunlei 开头,迅雷面板选择保存路径显示会去掉/xunlei前缀
|
||||
# 指定后可以在 volumes 中绑定宿主机实际目录
|
||||
# 迅雷云盘的缓存会使用第一个目录会缓存
|
||||
# /xunlei/后面可以用中文
|
||||
# 不设置默认一个目录 /xunlei/downloads
|
||||
- XL_DIR_DOWNLOAD=/xunlei/downloads:/xunlei/movies:/xunlei/apps
|
||||
volumes:
|
||||
# 对应 XL_DIR_DOWNLOAD 指定的目录, 请替换冒号前面的路径为实际路径
|
||||
- 实际【下载】文件夹路径:/xunlei/downloads
|
||||
- 实际【电影】文件夹路径:/xunlei/movies
|
||||
- 实际【软件】文件夹路径:/xunlei/apps
|
||||
# 数据目录,必须,迅雷运行时,插件,升级,包括登录数据都在这
|
||||
- ./data:/xunlei/data
|
||||
# 可选,不配置每次启动都会从远程下载spk
|
||||
- ./cache:/xunlei/var/packages/pan-xunlei-com
|
||||
```
|
||||
|
||||
## Used By
|
||||
|
||||
72
cmd.go
72
cmd.go
@ -1,7 +1,6 @@
|
||||
package xunlei
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
@ -11,6 +10,7 @@ import (
|
||||
"syscall"
|
||||
|
||||
"github.com/cnk3x/xunlei/pkg/log"
|
||||
"github.com/cnk3x/xunlei/pkg/utils"
|
||||
)
|
||||
|
||||
func cmdRun(ctx context.Context, name string, args []string, dir string, env []string, uid, gid uint32) (err error) {
|
||||
@ -40,45 +40,39 @@ func wrapConsole(ctx context.Context) io.WriteCloser {
|
||||
lv := slog.LevelDebug
|
||||
re0 := regexp.MustCompile(`^\d{2}/\d{2} \d{2}:\d{2}:\d{2}(\.\d+)? (INFO|ERROR|WARNING)\s*>?\s*`)
|
||||
re1 := regexp.MustCompile(`^[\dTZ:\.-]?\s*(INFO|ERROR|WARNING)\s*(\[\d+\])?\s*`)
|
||||
r, w := io.Pipe()
|
||||
go func() {
|
||||
for scan := bufio.NewScanner(r); scan.Scan(); {
|
||||
s := scan.Text()
|
||||
|
||||
if strings.Contains(s, `filter not match`) {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(s, `DetectPlatform err:`) {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(s, `detect err:key file lost`) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(s, "panic:"):
|
||||
lv = slog.LevelError
|
||||
case strings.HasPrefix(s, "RunSafe panic:"):
|
||||
lv = slog.LevelError
|
||||
case re0.MatchString(s):
|
||||
m := re0.FindStringSubmatch(s)
|
||||
if lv = log.LevelFromString(m[2], slog.LevelDebug); lv == slog.LevelInfo {
|
||||
lv = slog.LevelDebug
|
||||
}
|
||||
s = s[len(m[0]):]
|
||||
case re1.MatchString(s):
|
||||
m := re0.FindStringSubmatch(s)
|
||||
if lv = log.LevelFromString(m[1], slog.LevelDebug); lv == slog.LevelInfo {
|
||||
lv = slog.LevelDebug
|
||||
}
|
||||
s = s[len(m[0]):]
|
||||
}
|
||||
s = strings.ReplaceAll(s, `\u0000`, "")
|
||||
slog.Log(ctx, lv, s)
|
||||
return utils.LineWriter(func(s string) {
|
||||
if strings.Contains(s, `filter not match`) {
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
return w
|
||||
if strings.Contains(s, `DetectPlatform err:`) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.Contains(s, `detect err:key file lost`) {
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(s, "panic:"):
|
||||
lv = slog.LevelError
|
||||
case strings.HasPrefix(s, "RunSafe panic:"):
|
||||
lv = slog.LevelError
|
||||
case re0.MatchString(s):
|
||||
m := re0.FindStringSubmatch(s)
|
||||
if lv = log.LevelFromString(m[2], slog.LevelDebug); lv == slog.LevelInfo {
|
||||
lv = slog.LevelDebug
|
||||
}
|
||||
s = s[len(m[0]):]
|
||||
case re1.MatchString(s):
|
||||
m := re0.FindStringSubmatch(s)
|
||||
if lv = log.LevelFromString(m[1], slog.LevelDebug); lv == slog.LevelInfo {
|
||||
lv = slog.LevelDebug
|
||||
}
|
||||
s = s[len(m[0]):]
|
||||
}
|
||||
s = strings.ReplaceAll(s, `\u0000`, "")
|
||||
slog.Log(ctx, lv, s)
|
||||
})
|
||||
}
|
||||
|
||||
@ -47,7 +47,8 @@ func main() {
|
||||
slog.InfoContext(ctx, fmt.Sprintf("force_download: %t", cfg.ForceDownload))
|
||||
|
||||
if err := spk.Download(ctx, cfg.SpkUrl, filepath.Join(cfg.Chroot, xunlei.SYNOPKG_PKGDEST), cfg.ForceDownload); err != nil {
|
||||
return
|
||||
slog.ErrorContext(ctx, "exit", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if spkVer := utils.Cat(filepath.Join(cfg.Chroot, xunlei.PAN_XUNLEI_VER)); spkVer != "" {
|
||||
@ -58,21 +59,14 @@ func main() {
|
||||
slog.InfoContext(ctx, fmt.Sprintf(`xunlei version: %s`, cliVer))
|
||||
}
|
||||
|
||||
err := rootfs.Run(ctx,
|
||||
cfg.Chroot,
|
||||
xunlei.NewRun(cfg),
|
||||
rootfs.Before(xunlei.BeforeChroot(cfg)),
|
||||
if err := rootfs.Run(
|
||||
log.Prefix(ctx, "boot"),
|
||||
cfg.Chroot, xunlei.NewRun(cfg),
|
||||
rootfs.Basic,
|
||||
rootfs.MountBindRoot("/lib", cfg.Chroot),
|
||||
rootfs.MountBindRoot("/usr/lib", cfg.Chroot),
|
||||
rootfs.MountBindRoot("/lib64", cfg.Chroot, rootfs.Optional()),
|
||||
rootfs.MountBindRoot("/usr/lib64", cfg.Chroot, rootfs.Optional()),
|
||||
rootfs.MountBindRoot("/etc/ssl", cfg.Chroot, rootfs.Optional()),
|
||||
rootfs.LinkRoot("/etc/timezone", cfg.Chroot, 0666, true),
|
||||
rootfs.LinkRoot("/etc/resolv.conf", cfg.Chroot, 0666, true),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
rootfs.Before(xunlei.BeforeChroot(cfg)),
|
||||
rootfs.Binds(cfg.Chroot, "/lib", "/lib64", "/usr", "/sbin", "/bin", "/etc/ssl"),
|
||||
rootfs.Links(cfg.Chroot, "/etc/timezone", "/etc/localtime", "/etc/resolv.conf", "/etc/passwd", "/etc/group", "/etc/shadow"),
|
||||
); err != nil {
|
||||
slog.ErrorContext(ctx, "exit", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
@ -52,7 +52,7 @@ func ConfigBind(cfg *Config) (err error) {
|
||||
flags.Var(&cfg.Gid, "gid", "g", "运行迅雷的用户组ID", "XL_GID", "GID")
|
||||
flags.Var(&cfg.PreventUpdate, "prevent_update", "", "阻止更新", "XL_PREVENT_UPDATE")
|
||||
flags.Var(&cfg.Chroot, "chroot", "r", "CHROOT主目录", "XL_CHROOT")
|
||||
flags.Var(&cfg.SpkUrl, "spk", "", "SPK 下载链接", "XL_SPK_URL")
|
||||
flags.Var(&cfg.SpkUrl, "spk", "", "SPK 下载链接", "XL_SPK")
|
||||
flags.Var(&cfg.ForceDownload, "force_download", "F", "强制下载")
|
||||
flags.Var(&cfg.Debug, "debug", "", "是否开启调试日志", "XL_DEBUG")
|
||||
|
||||
|
||||
@ -49,6 +49,7 @@ words:
|
||||
- rootfs
|
||||
- shenzhen
|
||||
- softprops
|
||||
- succ
|
||||
- SYNO
|
||||
- synobios
|
||||
- synoinfo
|
||||
|
||||
25
debian.Dockerfile
Normal file
25
debian.Dockerfile
Normal file
@ -0,0 +1,25 @@
|
||||
FROM debian:stable-slim
|
||||
ARG TARGETARCH
|
||||
|
||||
LABEL org.opencontainers.image.authors=cnk3x
|
||||
LABEL org.opencontainers.image.source=https://github.com/cnk3x/xunlei
|
||||
|
||||
RUN apt update && apt install --no-install-recommends -y ca-certificates tzdata && rm -rf /var/lib/apt/lists/* && \
|
||||
rm -f /etc/localtime /etc/timezone && \
|
||||
cp -Lr /usr/share/zoneinfo/Asia/Chongqing /etc/localtime && \
|
||||
echo "Asia/Chongqing" >/etc/timezone
|
||||
|
||||
COPY artifacts/xlp-${TARGETARCH} /xlp
|
||||
|
||||
ENV XL_DASHBOARD_PORT=2345 \
|
||||
XL_DASHBOARD_IP= \
|
||||
XL_DASHBOARD_USERNAME= \
|
||||
XL_DIR_DOWNLOAD=/xunlei/downloads \
|
||||
XL_PREVENT_UPDATE= \
|
||||
XL_SPK_URL= \
|
||||
XL_UID= \
|
||||
XL_GID= \
|
||||
XL_DEBUG=
|
||||
|
||||
CMD [ "/xlp" ]
|
||||
|
||||
1
go.mod
1
go.mod
@ -3,7 +3,6 @@ module github.com/cnk3x/xunlei
|
||||
go 1.25.4
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/samber/lo v1.52.0
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/ulikunitz/xz v0.5.15
|
||||
|
||||
2
go.sum
2
go.sum
@ -1,5 +1,3 @@
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
|
||||
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
|
||||
@ -38,6 +38,15 @@ func Prefix(ctx context.Context, prefix string) context.Context {
|
||||
return context.WithValue(ctx, ctxPrefixKey, prefix)
|
||||
}
|
||||
|
||||
func PrefixAttr(prefix string) slog.Attr { return slog.String(prefixKey, prefix) }
|
||||
|
||||
func GetPrefix(ctx context.Context) slog.Attr {
|
||||
if v := ctx.Value(ctxPrefixKey); v != nil {
|
||||
return slog.String(prefixKey, v.(string))
|
||||
}
|
||||
return slog.Attr{}
|
||||
}
|
||||
|
||||
var (
|
||||
defaultLevel = slog.LevelInfo
|
||||
defaultTimeFormat = "01/02 15:04:05"
|
||||
|
||||
@ -3,14 +3,11 @@ package log
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"runtime"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Std(w io.Writer, prefix string) *log.Logger { return log.New(w, prefix, 0) }
|
||||
|
||||
func Writer(ctx context.Context) io.Writer {
|
||||
return &stdWriter{ctx: ctx, h: slog.Default().Handler()}
|
||||
}
|
||||
|
||||
@ -1,55 +0,0 @@
|
||||
package rootfs
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type MountOption func(*MountOptions)
|
||||
|
||||
func Mount(target string, options ...MountOption) MountOptions {
|
||||
mount := MountOptions{Target: target}
|
||||
for _, option := range options {
|
||||
option(&mount)
|
||||
}
|
||||
return mount
|
||||
}
|
||||
|
||||
func MountBind(v ...bool) MountOption {
|
||||
return func(mpo *MountOptions) { mpo.Bind = len(v) == 0 || cmp.Or(v...) }
|
||||
}
|
||||
|
||||
func MountOptional(v ...bool) MountOption {
|
||||
return func(mpo *MountOptions) { mpo.Optional = len(v) == 0 || cmp.Or(v...) }
|
||||
}
|
||||
|
||||
func Optional(v ...bool) MountOption {
|
||||
return func(mpo *MountOptions) { mpo.Optional = len(v) == 0 || cmp.Or(v...) }
|
||||
}
|
||||
|
||||
func Target(v string) MountOption { return func(mo *MountOptions) { mo.Target = v } }
|
||||
|
||||
type MountSourceOption func(*MountSourceOptions)
|
||||
|
||||
func MountSource(source string, options ...MountSourceOption) MountOption {
|
||||
s := MountSourceOptions{Source: source}
|
||||
for _, option := range options {
|
||||
option(&s)
|
||||
}
|
||||
return func(mpo *MountOptions) { mpo.Source = append(mpo.Source, s) }
|
||||
}
|
||||
|
||||
func MountType(source string, options ...MountSourceOption) MountOption {
|
||||
return MountSource(source, append(options, MountFstype(source))...)
|
||||
}
|
||||
|
||||
func MountFstype(v string) MountSourceOption { return func(ms *MountSourceOptions) { ms.Fstype = v } }
|
||||
func MountFlags(v uintptr) MountSourceOption { return func(ms *MountSourceOptions) { ms.Flags = v } }
|
||||
func MountData(v string) MountSourceOption {
|
||||
return func(mso *MountSourceOptions) {
|
||||
mso.Data += iif(mso.Data == "", "", ",") + v
|
||||
}
|
||||
}
|
||||
|
||||
func MountDataMode(perm string) MountSourceOption { return MountData(fmt.Sprintf("mode=%s", perm)) }
|
||||
func MountDataSize(size string) MountSourceOption { return MountData(fmt.Sprintf("size=%s", size)) }
|
||||
@ -5,21 +5,21 @@ import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/cnk3x/xunlei/pkg/rootfs/sys"
|
||||
)
|
||||
|
||||
const Separator = string(filepath.Separator)
|
||||
|
||||
// func Force(force bool) Option { return func(ro *RunOptions) { ro.force = force } }
|
||||
func After(after func() error) Option { return func(ro *RunOptions) { ro.after = after } }
|
||||
|
||||
func Before(before func(ctx context.Context) error) Option {
|
||||
return func(ro *RunOptions) { ro.before = before }
|
||||
}
|
||||
|
||||
func Link(source, target string, dirMode fs.FileMode, optional ...bool) Option {
|
||||
return func(ro *RunOptions) {
|
||||
ro.links = append(ro.links, LinkOptions{
|
||||
ro.links = append(ro.links, sys.LinkOptions{
|
||||
Target: target,
|
||||
Source: []string{source},
|
||||
Source: source,
|
||||
DirMode: dirMode,
|
||||
Optional: cmp.Or(optional...),
|
||||
})
|
||||
@ -30,30 +30,42 @@ func LinkRoot(source, newRoot string, dirMode fs.FileMode, optional ...bool) Opt
|
||||
return Link(source, filepath.Join(newRoot, source), dirMode, optional...)
|
||||
}
|
||||
|
||||
func MountRoot(source, newRoot string, options ...MountOption) Option {
|
||||
return func(ro *RunOptions) {
|
||||
ro.mounts = append(ro.mounts, Mount(filepath.Join(newRoot, source), options...))
|
||||
}
|
||||
}
|
||||
|
||||
func MountBindRoot(source, newRoot string, options ...MountOption) Option {
|
||||
return func(ro *RunOptions) {
|
||||
ro.mounts = append(ro.mounts,
|
||||
Mount(
|
||||
filepath.Join(newRoot, source),
|
||||
append(options, MountSource(source), MountBind())...,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func Basic(ro *RunOptions) {
|
||||
optional := MountOptional(true)
|
||||
|
||||
ro.mounts = append(ro.mounts,
|
||||
Mount(filepath.Join(ro.root, "proc"), MountType("proc")), // 挂载proc文件系统(必须)
|
||||
Mount(filepath.Join(ro.root, "dev"), MountType("devtmpfs", MountDataMode("0755")), MountType("tmpfs", MountDataMode("0755"))), // 挂载devtmpfs到/dev(提供基础设备节点)
|
||||
Mount(filepath.Join(ro.root, "sys"), MountType("sysfs"), optional), // 挂载sysfs文件系统(可选,但建议挂载)
|
||||
Mount(filepath.Join(ro.root, "tmp"), MountType("tmpfs", MountDataSize("100m"), MountDataMode("0777")), optional), // 挂载tmpfs到/tmp(临时目录,可选)
|
||||
// 挂载proc文件系统(必须)
|
||||
sys.MountOptions{Target: filepath.Join(ro.root, "proc"), Source: "proc", Fstype: "proc"},
|
||||
// 挂载devtmpfs到/dev(提供基础设备节点)
|
||||
sys.MountOptions{Target: filepath.Join(ro.root, "dev"), Source: "devtmpfs", Fstype: "devtmpfs", Data: "mode=0755"}, //tmpfs
|
||||
// 挂载sysfs文件系统(可选,但建议挂载)
|
||||
sys.MountOptions{Target: filepath.Join(ro.root, "sys"), Source: "sysfs", Fstype: "sysfs", Optional: true},
|
||||
// 挂载tmpfs到/tmp(临时目录,可选)
|
||||
sys.MountOptions{Target: filepath.Join(ro.root, "tmp"), Source: "tmpfs", Fstype: "tmpfs", Data: "mode=0777,size=100m", Optional: true},
|
||||
)
|
||||
}
|
||||
|
||||
func Links(root string, files ...string) Option {
|
||||
return func(ro *RunOptions) {
|
||||
for _, file := range files {
|
||||
mOpts := sys.LinkOptions{
|
||||
Target: filepath.Join(root, file),
|
||||
Source: file,
|
||||
Optional: true,
|
||||
DirMode: 0777,
|
||||
}
|
||||
ro.links = append(ro.links, mOpts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func Binds(root string, dirs ...string) func(ro *RunOptions) {
|
||||
return func(ro *RunOptions) {
|
||||
for _, dir := range dirs {
|
||||
mOpts := sys.BindOptions{
|
||||
Target: filepath.Join(root, dir),
|
||||
Source: dir,
|
||||
Optional: true,
|
||||
}
|
||||
ro.binds = append(ro.binds, mOpts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,62 +1,33 @@
|
||||
package rootfs
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/cnk3x/xunlei/pkg/fo"
|
||||
"github.com/cnk3x/xunlei/pkg/log"
|
||||
"github.com/cnk3x/xunlei/pkg/utils"
|
||||
"github.com/cnk3x/xunlei/pkg/rootfs/sys"
|
||||
"github.com/samber/lo"
|
||||
)
|
||||
|
||||
type Undo func()
|
||||
// type Undo func()
|
||||
type Option func(ro *RunOptions)
|
||||
|
||||
type Options []Option
|
||||
|
||||
func (o Options) Add(options ...Option) Options { return append(o, options...) }
|
||||
|
||||
type RunOptions struct {
|
||||
root string
|
||||
mounts []MountOptions
|
||||
links []LinkOptions
|
||||
// force bool
|
||||
|
||||
mounts []sys.MountOptions
|
||||
binds []sys.BindOptions
|
||||
links []sys.LinkOptions
|
||||
before func(ctx context.Context) error
|
||||
after func() error
|
||||
}
|
||||
|
||||
type MountOptions struct {
|
||||
Target string
|
||||
Source []MountSourceOptions
|
||||
Bind bool
|
||||
Optional bool
|
||||
}
|
||||
|
||||
type MountSourceOptions struct {
|
||||
Source string
|
||||
Fstype string
|
||||
Flags uintptr
|
||||
Data string
|
||||
}
|
||||
|
||||
type LinkOptions struct {
|
||||
Target string
|
||||
Source []string
|
||||
DirMode fs.FileMode
|
||||
Optional bool
|
||||
}
|
||||
|
||||
func Run(ctx context.Context, newRoot string, run func(ctx context.Context) error, options ...Option) (err error) {
|
||||
ctx = log.Prefix(ctx, "boot")
|
||||
|
||||
slog.InfoContext(ctx, "start boot")
|
||||
|
||||
defer func() {
|
||||
@ -88,26 +59,26 @@ func Run(ctx context.Context, newRoot string, run func(ctx context.Context) erro
|
||||
// }()
|
||||
|
||||
//undos
|
||||
var undos []Undo
|
||||
defer execUndo(makeUndos(&undos), nil)
|
||||
var undos []sys.Undo
|
||||
defer sys.ExecUndo(sys.Undos(&undos), nil)
|
||||
|
||||
var undo Undo
|
||||
var undo sys.Undo
|
||||
|
||||
//mkdirs
|
||||
dirs := lo.Map(opts.mounts, func(m MountOptions, _ int) string { return m.Target })
|
||||
if undo, err = Mkdirs(ctx, dirs, 0777, true); err != nil {
|
||||
dirs := lo.Map(opts.mounts, func(m sys.MountOptions, _ int) string { return m.Target })
|
||||
if undo, err = sys.Mkdirs(ctx, dirs, 0777); err != nil {
|
||||
return
|
||||
}
|
||||
undos = append(undos, undo)
|
||||
|
||||
//mounts
|
||||
if undo, err = mounts(ctx, opts.mounts); err != nil {
|
||||
if undo, err = sys.Mounts(ctx, opts.mounts); err != nil {
|
||||
return
|
||||
}
|
||||
undos = append(undos, undo)
|
||||
|
||||
//links
|
||||
if undo, err = links(ctx, opts.links); err != nil {
|
||||
if undo, err = sys.Links(ctx, opts.links); err != nil {
|
||||
return
|
||||
}
|
||||
undos = append(undos, undo)
|
||||
@ -129,256 +100,6 @@ func Run(ctx context.Context, newRoot string, run func(ctx context.Context) erro
|
||||
}
|
||||
|
||||
//chroot & run
|
||||
err = chroot(log.Prefix(ctx, "prog"), opts.root, run)
|
||||
err = sys.Chroot(log.Prefix(ctx, "prog"), opts.root, run)
|
||||
return
|
||||
}
|
||||
|
||||
// chroot & run
|
||||
func chroot(ctx context.Context, newRoot string, run func(ctx context.Context) error) (err error) {
|
||||
wd, e := os.Getwd()
|
||||
if err = e; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var rfd int
|
||||
if rfd, err = syscall.Open("/", syscall.O_RDONLY, 0); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
logWithErr(ctx, syscall.Close(rfd), "closeFd", "fd", rfd)
|
||||
}()
|
||||
|
||||
if err = logWithErr(ctx, syscall.Chdir(newRoot), "chdir", "dir", newRoot); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = logWithErr(ctx, syscall.Chroot("."), "chroot", "path", "."); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
logWithErr(ctx, syscall.Fchdir(rfd), "fchdir", "fd", rfd)
|
||||
logWithErr(ctx, syscall.Chroot("."), "chroot rollback", "path", ".")
|
||||
logWithErr(ctx, syscall.Chdir(wd), "chdir", "dir", wd)
|
||||
}()
|
||||
|
||||
if err = logWithErr(ctx, syscall.Chdir("/"), "chdir", "dir", "/"); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
//run
|
||||
err = run(ctx)
|
||||
return
|
||||
}
|
||||
|
||||
func Mkdirs(ctx context.Context, dirs []string, perm fs.FileMode, existsOk bool) (undo Undo, err error) {
|
||||
var undos []Undo
|
||||
undo = makeUndos(&undos)
|
||||
defer execUndo(undo, &err)
|
||||
for _, dir := range dirs {
|
||||
u, e := Mkdir(ctx, dir, perm, existsOk)
|
||||
if e != nil {
|
||||
err = e
|
||||
return
|
||||
}
|
||||
undos = append(undos, u)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 脱了裤子放个屁,为了能够方便回滚
|
||||
func Mkdir(ctx context.Context, dir string, perm fs.FileMode, existsOk bool) (undo Undo, err error) {
|
||||
if dir, err = filepath.Abs(dir); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var undos []Undo
|
||||
undo = makeUndos(&undos)
|
||||
defer execUndo(undo, &err)
|
||||
|
||||
vol := filepath.VolumeName(dir)
|
||||
if vol != "" {
|
||||
dir = dir[len(vol):]
|
||||
}
|
||||
dir = strings.Trim(dir, Separator)
|
||||
|
||||
fields := strings.FieldsFunc(dir, func(r rune) bool { return r == filepath.Separator })
|
||||
for i := range fields {
|
||||
full := filepath.Join(cmp.Or(vol, Separator), filepath.Join(fields[:i+1]...))
|
||||
switch stat, e := os.Stat(full); {
|
||||
case e != nil && !os.IsNotExist(e):
|
||||
err = e
|
||||
case e != nil:
|
||||
if err = os.Mkdir(full, perm); err != nil {
|
||||
err = fmt.Errorf("mkdir fail: %w: %s", err, full)
|
||||
} else {
|
||||
slog.DebugContext(ctx, "mkdir", "path", full)
|
||||
undos = append(undos, func() { logWithErr(ctx, os.Remove(full), "rmdir", "path", full) })
|
||||
}
|
||||
case !stat.IsDir():
|
||||
err = fmt.Errorf("mkdir fail, parent is not a directory: %w: %s", fs.ErrExist, full)
|
||||
case !existsOk:
|
||||
err = fmt.Errorf("mkdir fail, parent is exists: %w: %s", fs.ErrExist, full)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "mkdir", "path", full, "err", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func mounts(ctx context.Context, mountPoints []MountOptions) (undo Undo, err error) {
|
||||
var undos []Undo
|
||||
undo = makeUndos(&undos)
|
||||
defer execUndo(undo, &err)
|
||||
|
||||
for _, m := range mountPoints {
|
||||
r, e := mount(ctx, m)
|
||||
if e != nil {
|
||||
err = e
|
||||
return
|
||||
}
|
||||
undos = append(undos, r)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func mount(ctx context.Context, m MountOptions) (undo Undo, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
slog.DebugContext(ctx, string(utils.Eon(json.Marshal(m))))
|
||||
}
|
||||
}()
|
||||
|
||||
defer execUndo(undo, &err)
|
||||
|
||||
if len(m.Source) == 0 {
|
||||
slog.WarnContext(ctx, "mount", "target", m.Target, "optional", m.Optional, "err", "source not provided")
|
||||
if !m.Optional {
|
||||
err = fmt.Errorf("source not provided")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for _, mm := range m.Source {
|
||||
src := mm.Source
|
||||
|
||||
flag := mm.Flags
|
||||
if m.Bind {
|
||||
if src, err = filepath.EvalSymlinks(src); err != nil {
|
||||
continue
|
||||
}
|
||||
if flag == 0 {
|
||||
flag = syscall.MS_BIND
|
||||
// | syscall.MS_REC, //todo: 是否需要递归绑定?
|
||||
}
|
||||
}
|
||||
|
||||
if err = syscall.Mount(src, m.Target, mm.Fstype, flag, mm.Data); err != nil {
|
||||
slog.WarnContext(ctx, "mount", "target", m.Target, "source", src, "optional", m.Optional, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
unmount := func() error { return syscall.Unmount(m.Target, syscall.MNT_DETACH|syscall.MNT_FORCE) }
|
||||
undo = func() { logWithErr(ctx, unmount(), "unmount", "target", m.Target) }
|
||||
|
||||
slog.DebugContext(ctx, "mount", "target", m.Target, "source", src, "optional", m.Optional)
|
||||
break
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func links(ctx context.Context, links []LinkOptions) (undo Undo, err error) {
|
||||
var undos []Undo
|
||||
undo = makeUndos(&undos)
|
||||
defer execUndo(undo, &err)
|
||||
|
||||
for _, l := range links {
|
||||
for _, source := range l.Source {
|
||||
r, e := link(ctx, source, l.Target, l.DirMode)
|
||||
if e != nil {
|
||||
err = e
|
||||
return
|
||||
}
|
||||
undos = append(undos, r)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// link hard link, only for file
|
||||
func link(ctx context.Context, source string, linkFile string, dirMode fs.FileMode) (undo Undo, err error) {
|
||||
var undos []Undo
|
||||
undo = makeUndos(&undos)
|
||||
defer execUndo(undo, &err)
|
||||
|
||||
var realPath string
|
||||
if realPath, err = filepath.EvalSymlinks(source); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var dirUndo Undo
|
||||
if dirUndo, err = Mkdir(ctx, filepath.Dir(linkFile), dirMode, true); err != nil {
|
||||
return
|
||||
}
|
||||
undos = append(undos, dirUndo)
|
||||
|
||||
op := "link"
|
||||
if err = os.Link(realPath, linkFile); errors.Is(err, syscall.EXDEV) {
|
||||
op, err = "copy", fileCopy(realPath, linkFile)
|
||||
}
|
||||
|
||||
logWithErr(ctx, err, "link", "op", op, "link", linkFile, "source", source, "realpath", realPath)
|
||||
undos = append(undos, func() { logWithErr(ctx, os.Remove(linkFile), "unlink", "link", linkFile) })
|
||||
return
|
||||
}
|
||||
|
||||
func fileCopy(source, target string) error {
|
||||
return fo.OpenRead(source, func(src *os.File) (err error) { return fo.OpenWrite(target, fo.From(src), fo.PermFrom(src)) })
|
||||
}
|
||||
|
||||
func iif[T any](c bool, t, f T) T {
|
||||
if c {
|
||||
return t
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func execUndo(undo Undo, err *error) {
|
||||
if err == nil || *err != nil && undo != nil {
|
||||
undo()
|
||||
}
|
||||
}
|
||||
|
||||
func makeUndos(undos *[]Undo) (undo Undo) {
|
||||
return func() {
|
||||
if undos == nil || len(*undos) == 0 {
|
||||
return
|
||||
}
|
||||
for _, undo := range slices.Backward(*undos) {
|
||||
if undo != nil {
|
||||
undo()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func logWithErr(ctx context.Context, err error, msg string, args ...any) error {
|
||||
switch {
|
||||
case errors.Is(err, syscall.ENOTEMPTY):
|
||||
slog.Log(ctx, slog.LevelDebug, msg, append(args, "err", "skip because not empty")...)
|
||||
case errors.Is(err, context.Canceled):
|
||||
slog.Log(ctx, slog.LevelDebug, msg, append(args, "err", context.Cause(ctx))...)
|
||||
case err != nil:
|
||||
slog.Log(ctx, slog.LevelWarn, msg, append(args, "err", err)...)
|
||||
default:
|
||||
slog.Log(ctx, slog.LevelDebug, msg, args...)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
39
pkg/rootfs/sys/bind.go
Normal file
39
pkg/rootfs/sys/bind.go
Normal file
@ -0,0 +1,39 @@
|
||||
package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type BindOptions struct {
|
||||
Source string
|
||||
Target string
|
||||
Optional bool
|
||||
}
|
||||
|
||||
func Binds(ctx context.Context, items []BindOptions) (undo Undo, err error) {
|
||||
return doMulti(ctx, items, Bind)
|
||||
}
|
||||
|
||||
// 绑定文件夹
|
||||
func Bind(ctx context.Context, m BindOptions) (undo Undo, err error) {
|
||||
src := m.Source
|
||||
if src, err = filepath.EvalSymlinks(src); err == nil {
|
||||
err = syscall.Mount(src, m.Target, "", syscall.MS_BIND, "")
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
undo = mkUnmount(ctx, m.Target, "unbind")
|
||||
}
|
||||
|
||||
attrs := []slog.Attr{
|
||||
slog.String("target", m.Target),
|
||||
slog.String("source", m.Source),
|
||||
slog.String("source_real", src),
|
||||
}
|
||||
|
||||
err = logIt(ctx, err, m.Optional, "bind", attrs...)
|
||||
return
|
||||
}
|
||||
39
pkg/rootfs/sys/chroot.go
Normal file
39
pkg/rootfs/sys/chroot.go
Normal file
@ -0,0 +1,39 @@
|
||||
package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// chroot & run
|
||||
func Chroot(ctx context.Context, newRoot string, run func(ctx context.Context) error) (err error) {
|
||||
wd, e := os.Getwd()
|
||||
if err = e; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err = syscall.Chdir(newRoot); err != nil {
|
||||
return
|
||||
}
|
||||
defer syscall.Chdir(wd)
|
||||
|
||||
var rfd int
|
||||
if rfd, err = syscall.Open("/", syscall.O_RDONLY, 0); err != nil {
|
||||
return
|
||||
}
|
||||
defer syscall.Close(rfd)
|
||||
|
||||
if err = syscall.Chroot("."); err != nil {
|
||||
return
|
||||
}
|
||||
defer syscall.Chroot(".")
|
||||
defer syscall.Fchdir(rfd)
|
||||
|
||||
if err = syscall.Chdir("/"); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = run(ctx)
|
||||
return
|
||||
}
|
||||
88
pkg/rootfs/sys/link.go
Normal file
88
pkg/rootfs/sys/link.go
Normal file
@ -0,0 +1,88 @@
|
||||
package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/cnk3x/xunlei/pkg/fo"
|
||||
)
|
||||
|
||||
type LinkOptions struct {
|
||||
Target string
|
||||
Source string
|
||||
DirMode fs.FileMode
|
||||
Optional bool
|
||||
Copy bool
|
||||
}
|
||||
|
||||
func Links(ctx context.Context, links []LinkOptions) (undo Undo, err error) {
|
||||
var undos []Undo
|
||||
undo = Undos(&undos)
|
||||
defer ExecUndo(undo, &err)
|
||||
for _, m := range links {
|
||||
u, e := Link(ctx, m)
|
||||
if err = e; e != nil {
|
||||
return
|
||||
}
|
||||
undos = append(undos, u)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// link hard link, only for file
|
||||
func Link(ctx context.Context, m LinkOptions) (undo Undo, err error) {
|
||||
var undos []Undo
|
||||
undo = Undos(&undos)
|
||||
defer ExecUndo(undo, &err)
|
||||
|
||||
real, e := filepath.EvalSymlinks(m.Source)
|
||||
if err = e; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
dirUndo, e := Mkdir(ctx, filepath.Dir(m.Target), m.DirMode)
|
||||
if err = e; err != nil {
|
||||
return
|
||||
}
|
||||
undos = append(undos, dirUndo)
|
||||
|
||||
err = os.Link(real, m.Target)
|
||||
if errors.Is(err, syscall.EXDEV) {
|
||||
err = fo.OpenRead(real, func(src *os.File) (err error) {
|
||||
return fo.OpenWrite(m.Target, fo.From(src), fo.PermFrom(src), fo.FlagExcl)
|
||||
})
|
||||
}
|
||||
|
||||
attrs := []slog.Attr{
|
||||
slog.String("target", m.Target),
|
||||
slog.String("source", m.Source),
|
||||
slog.String("source_real", real),
|
||||
slog.Bool("optional", m.Optional),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
attrs = append(attrs, slog.String("err", err.Error()))
|
||||
}
|
||||
|
||||
switch {
|
||||
case os.IsExist(err):
|
||||
slog.LogAttrs(ctx, slog.LevelDebug, "link skip", attrs...)
|
||||
case err != nil && m.Optional:
|
||||
slog.LogAttrs(ctx, slog.LevelDebug, "link skip", attrs...)
|
||||
case err != nil && !m.Optional:
|
||||
slog.LogAttrs(ctx, slog.LevelWarn, "link fail", attrs...)
|
||||
default:
|
||||
slog.LogAttrs(ctx, slog.LevelDebug, "link done", attrs...)
|
||||
undos = append(undos, newRm(ctx, m.Target, "unlink"))
|
||||
}
|
||||
|
||||
if os.IsExist(err) {
|
||||
err = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
93
pkg/rootfs/sys/mkdir.go
Normal file
93
pkg/rootfs/sys/mkdir.go
Normal file
@ -0,0 +1,93 @@
|
||||
package sys
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func Mkdirs(ctx context.Context, dirs []string, perm fs.FileMode) (undo Undo, err error) {
|
||||
return doMulti(ctx, dirs,
|
||||
func(ctx context.Context, dir string) (Undo, error) {
|
||||
return Mkdir(ctx, dir, perm)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 脱了裤子放个屁,为了能够方便回滚
|
||||
func Mkdir(ctx context.Context, dir string, perm fs.FileMode) (undo Undo, err error) {
|
||||
if dir, err = filepath.Abs(dir); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var undos []Undo
|
||||
undo = Undos(&undos)
|
||||
defer ExecUndo(undo, &err)
|
||||
|
||||
vol := cmp.Or(filepath.VolumeName(dir), "/")
|
||||
items := strings.FieldsFunc(strings.TrimPrefix(dir, vol), func(r rune) bool { return r == '/' })
|
||||
|
||||
var ok bool
|
||||
for i := range items {
|
||||
ok, err = mkdir(filepath.Join(vol, filepath.Join(items[:i+1]...)), perm)
|
||||
}
|
||||
|
||||
switch {
|
||||
case err != nil:
|
||||
slog.WarnContext(ctx, "mkdir fail", "dir", dir, "err", err)
|
||||
return
|
||||
case ok:
|
||||
undos = append(undos, newRm(ctx, dir, "rmdir"))
|
||||
slog.DebugContext(ctx, "mkdir done", "dir", dir)
|
||||
// default:
|
||||
// slog.DebugContext(ctx, "mkdir skip", "dir", dir)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func newRm(ctx context.Context, target, act string) func() {
|
||||
return func() {
|
||||
err := os.Remove(target)
|
||||
if err != nil {
|
||||
slog.LogAttrs(ctx, slog.LevelWarn, act, slog.String("target", target), slog.String("err", err.Error()))
|
||||
return
|
||||
}
|
||||
slog.LogAttrs(ctx, slog.LevelDebug, act, slog.String("target", target))
|
||||
}
|
||||
}
|
||||
|
||||
func mkdir(dir string, perm fs.FileMode) (ok bool, err error) {
|
||||
stat, e := os.Stat(dir)
|
||||
//存在
|
||||
if err = e; err == nil {
|
||||
if stat.IsDir() { //目录存在, 跳过
|
||||
return
|
||||
}
|
||||
|
||||
if stat.Mode()&os.ModeSymlink != 0 { //软链接, 删除
|
||||
err = os.Remove(dir)
|
||||
} else { //文件存在, 报错
|
||||
err = fmt.Errorf("%w: %s", os.ErrExist, dir)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
//非文件不存在, 报错
|
||||
if !os.IsNotExist(err) {
|
||||
return
|
||||
}
|
||||
|
||||
//创建目录, 上级目录存在
|
||||
err = os.Mkdir(dir, perm)
|
||||
ok = err == nil
|
||||
return
|
||||
}
|
||||
54
pkg/rootfs/sys/mount.go
Normal file
54
pkg/rootfs/sys/mount.go
Normal file
@ -0,0 +1,54 @@
|
||||
package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"syscall"
|
||||
|
||||
"github.com/cnk3x/xunlei/pkg/utils"
|
||||
)
|
||||
|
||||
type MountOptions struct {
|
||||
Target string
|
||||
Source string
|
||||
Fstype string
|
||||
Flags uintptr
|
||||
Data string
|
||||
Optional bool
|
||||
}
|
||||
|
||||
func Mounts(ctx context.Context, mounts []MountOptions) (undo Undo, err error) {
|
||||
return doMulti(ctx, mounts, Mount)
|
||||
}
|
||||
|
||||
// 完整的绑定
|
||||
func Mount(ctx context.Context, m MountOptions) (undo Undo, err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
slog.DebugContext(ctx, string(utils.Eon(json.Marshal(m))))
|
||||
}
|
||||
}()
|
||||
|
||||
err = syscall.Mount(m.Source, m.Target, m.Fstype, m.Flags, m.Data)
|
||||
if err == nil {
|
||||
undo = mkUnmount(ctx, m.Target, "unmount")
|
||||
}
|
||||
|
||||
err = logIt(ctx, err, m.Optional, "mount",
|
||||
slog.String("target", m.Target),
|
||||
slog.String("source", m.Source),
|
||||
slog.Bool("optional", m.Optional))
|
||||
return
|
||||
}
|
||||
|
||||
func mkUnmount(ctx context.Context, target, act string) Undo {
|
||||
return func() {
|
||||
err := syscall.Unmount(target, syscall.MNT_DETACH|syscall.MNT_FORCE)
|
||||
if err != nil {
|
||||
slog.LogAttrs(ctx, slog.LevelWarn, act, slog.String("target", target), slog.String("err", err.Error()))
|
||||
return
|
||||
}
|
||||
slog.LogAttrs(ctx, slog.LevelDebug, act, slog.String("target", target))
|
||||
}
|
||||
}
|
||||
58
pkg/rootfs/sys/sys.go
Normal file
58
pkg/rootfs/sys/sys.go
Normal file
@ -0,0 +1,58 @@
|
||||
package sys
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"github.com/cnk3x/xunlei/pkg/utils"
|
||||
)
|
||||
|
||||
type Undo = func()
|
||||
|
||||
func doMulti[O any](ctx context.Context, items []O, itemFn func(context.Context, O) (Undo, error)) (undo Undo, err error) {
|
||||
var undos []Undo
|
||||
undo = Undos(&undos)
|
||||
defer ExecUndo(undo, &err)
|
||||
for _, item := range items {
|
||||
u, e := itemFn(ctx, item)
|
||||
if err = e; e != nil {
|
||||
return
|
||||
}
|
||||
undos = append(undos, u)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func logIt(ctx context.Context, err error, optional bool, name string, attrs ...slog.Attr) error {
|
||||
if err != nil {
|
||||
attrs = append(attrs, slog.String("err", err.Error()))
|
||||
if optional {
|
||||
slog.LogAttrs(ctx, slog.LevelDebug, name+" skip", attrs...)
|
||||
} else {
|
||||
slog.LogAttrs(ctx, slog.LevelWarn, name+" fail", attrs...)
|
||||
}
|
||||
} else {
|
||||
slog.LogAttrs(ctx, slog.LevelDebug, name+" done", attrs...)
|
||||
}
|
||||
return utils.Iif(optional, nil, err)
|
||||
}
|
||||
|
||||
func ExecUndo(undo Undo, err *error) {
|
||||
if err == nil || *err != nil && undo != nil {
|
||||
undo()
|
||||
}
|
||||
}
|
||||
|
||||
func Undos(undos *[]Undo) (undo Undo) {
|
||||
return func() {
|
||||
if undos == nil || len(*undos) == 0 {
|
||||
return
|
||||
}
|
||||
for _, undo := range slices.Backward(*undos) {
|
||||
if undo != nil {
|
||||
undo()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,12 @@
|
||||
package utils
|
||||
|
||||
import "cmp"
|
||||
import (
|
||||
"bufio"
|
||||
"cmp"
|
||||
"io"
|
||||
"iter"
|
||||
"log"
|
||||
)
|
||||
|
||||
func CompactUniq[Slice ~[]T, T comparable](s Slice, inplace ...bool) Slice {
|
||||
result := s
|
||||
@ -54,3 +60,43 @@ func Flat[T any](s [][]T) []T {
|
||||
l := Reduce(s, func(agg int, item []T, _ int) int { return agg + len(item) }, 0)
|
||||
return Reduce(s, func(agg []T, item []T, _ int) []T { return append(agg, item...) }, make([]T, 0, l))
|
||||
}
|
||||
|
||||
func ReduceSeq2[T, R any](seq iter.Seq2[int, T], walk func(agg R, item T, index int) R, init R) R {
|
||||
for i, item := range seq {
|
||||
init = walk(init, item, i)
|
||||
}
|
||||
return init
|
||||
}
|
||||
|
||||
func ReduceSeq[T, R any](seq iter.Seq[T], walk func(agg R, item T) R, init R) R {
|
||||
for item := range seq {
|
||||
init = walk(init, item)
|
||||
}
|
||||
return init
|
||||
}
|
||||
|
||||
func LineSeq(r io.Reader) iter.Seq[string] {
|
||||
return func(yield func(string) bool) {
|
||||
for scan := bufio.NewScanner(r); scan.Scan(); {
|
||||
if !yield(scan.Text()) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func LineWalk(r io.Reader, f func(s string)) {
|
||||
for scan := bufio.NewScanner(r); scan.Scan(); {
|
||||
f(scan.Text())
|
||||
}
|
||||
}
|
||||
|
||||
func LineWriter(lineRead func(line string)) io.WriteCloser {
|
||||
r, w := io.Pipe()
|
||||
go LineWalk(r, lineRead)
|
||||
return w
|
||||
}
|
||||
|
||||
func LogStd(w io.Writer) *log.Logger { return log.New(w, "", 0) }
|
||||
|
||||
func Array[T any](s ...T) []T { return s }
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SelectOnce select `cSelect` chan, return it
|
||||
// SelectOnce select `cSelect` chan, return it (blocked)
|
||||
//
|
||||
// v: if recv from cSelect
|
||||
// ok: if cSelect is not closed
|
||||
@ -16,31 +15,89 @@ import (
|
||||
// select{
|
||||
// case <- cBreaks[...]:
|
||||
// v = nil, ok = false, breaked = true
|
||||
// case v, ok:= cSeelct:
|
||||
// case v, ok:= cSelect:
|
||||
// v = v, ok = ok, breaked = false
|
||||
// }
|
||||
func SelectOnce[T any](cSelect <-chan T, cBreaks ...<-chan struct{}) (v T, ok, breaked bool) {
|
||||
c2case := func(c <-chan struct{}, _ int) reflect.SelectCase {
|
||||
return reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(c)}
|
||||
func CSelect[T any](cSelect <-chan T, cBreaks ...<-chan struct{}) (v T, ok, breaked bool) {
|
||||
cBreaked := make(chan struct{})
|
||||
|
||||
for _, c := range cBreaks {
|
||||
go func() {
|
||||
select {
|
||||
case <-cBreaked:
|
||||
case <-c:
|
||||
close(cBreaked)
|
||||
}
|
||||
}()
|
||||
}
|
||||
cases := append(Map(cBreaks, c2case), reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(cSelect)})
|
||||
if chosen, x, ok := reflect.Select(cases); chosen == len(cases)-1 {
|
||||
v, _ = x.Interface().(T)
|
||||
return v, ok, false
|
||||
|
||||
select {
|
||||
case <-cBreaked:
|
||||
breaked = true
|
||||
case v, ok = <-cSelect:
|
||||
close(cBreaked)
|
||||
}
|
||||
return v, false, true
|
||||
return
|
||||
}
|
||||
|
||||
// SelectDo do if recv from sSelect
|
||||
func SelectDo[T any](cSelect <-chan T, okFunc func(), cBreaks ...<-chan struct{}) {
|
||||
if _, _, breaked := SelectOnce(cSelect, cBreaks...); !breaked {
|
||||
okFunc()
|
||||
// SelectDo do if recv from sSelect (blocked)
|
||||
func SelectDo[T any](cSelect <-chan T, f func(T, bool), cBreaks ...<-chan struct{}) (breaked bool) {
|
||||
cBreaked := make(chan struct{})
|
||||
|
||||
for _, c := range cBreaks {
|
||||
go func() {
|
||||
select {
|
||||
case <-cBreaked:
|
||||
case <-c:
|
||||
close(cBreaked)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
select {
|
||||
case <-cBreaked:
|
||||
return true
|
||||
case v, ok := <-cSelect:
|
||||
close(cBreaked)
|
||||
f(v, ok)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Sleep breakable sleep (blocked)
|
||||
func Sleep(d time.Duration, cBreaks ...<-chan struct{}) (breaked bool) {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
_, _, breaked = SelectOnce(t.C, cBreaks...)
|
||||
_, _, breaked = CSelect(t.C, cBreaks...)
|
||||
return
|
||||
}
|
||||
|
||||
// After do when done is closed (unblocked)
|
||||
func After[T any, F ft[T]](done <-chan T, f F, cBreaks ...<-chan struct{}) {
|
||||
go SelectDo(done, func(t T, ok bool) {
|
||||
af := any(f)
|
||||
if ft, ok := af.(func()); ok {
|
||||
ft()
|
||||
return
|
||||
}
|
||||
|
||||
if ft, ok := af.(func() error); ok {
|
||||
_ = ft()
|
||||
return
|
||||
}
|
||||
|
||||
if ft, ok := af.(func(T)); ok {
|
||||
ft(t)
|
||||
return
|
||||
}
|
||||
|
||||
if ft, ok := af.(func(T) error); ok {
|
||||
_ = ft(t)
|
||||
return
|
||||
}
|
||||
}, cBreaks...)
|
||||
}
|
||||
|
||||
type ft[T any] interface {
|
||||
~func() | ~func() error | ~func(T) | ~func(T) error
|
||||
}
|
||||
|
||||
@ -7,3 +7,8 @@ func Fig[I, T, R any](f func(T) R) func(T, I) R {
|
||||
func Fig2[I, T1, T2, R any](f func(T1, T2) R) func(T1, T2, I) R {
|
||||
return func(t1 T1, t2 T2, _ I) R { return f(t1, t2) }
|
||||
}
|
||||
|
||||
func FIdx[T, R any](f func(T) R) func(T, int) R { return Fig[int](f) }
|
||||
|
||||
func Fe(f func()) error { f(); return nil }
|
||||
func Fne[E any](f func() E) func() { return func() { _ = f() } }
|
||||
|
||||
@ -7,7 +7,7 @@ import (
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func HumanBytes[T uintT | intT](n T, prec ...int) string {
|
||||
func HumanBytes[T UintT | IntT](n T, prec ...int) string {
|
||||
if f := float64(n); f >= 1024 {
|
||||
for i, u := range slices.Backward([]rune("KMGTE")) {
|
||||
if base := float64(int64(1) << (10 * (i + 1))); float64(n) >= base {
|
||||
|
||||
@ -2,15 +2,15 @@ package utils
|
||||
|
||||
import "strconv"
|
||||
|
||||
type uintT interface {
|
||||
type UintT interface {
|
||||
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64
|
||||
}
|
||||
|
||||
type intT interface {
|
||||
type IntT interface {
|
||||
~int | ~int8 | ~int16 | ~int32 | ~int64
|
||||
}
|
||||
|
||||
func StrParser[R uintT | intT, T1, T2 any, R64 uint64 | int64](f func(string, T1, T2) (R64, error), t1 T1, t2 T2) func(string) (R, error) {
|
||||
func StrParser[R UintT | IntT, T1, T2 any, R64 uint64 | int64](f func(string, T1, T2) (R64, error), t1 T1, t2 T2) func(string) (R, error) {
|
||||
return func(s string) (R, error) {
|
||||
r, err := f(s, t1, t2)
|
||||
if err != nil {
|
||||
@ -32,3 +32,7 @@ var (
|
||||
ParseInt32 = StrParser[int32](strconv.ParseInt, 0, 32)
|
||||
ParseInt64 = StrParser[int64](strconv.ParseInt, 0, 64)
|
||||
)
|
||||
|
||||
func String[T IntT | UintT](v T) string {
|
||||
return strconv.FormatInt(int64(v), 10)
|
||||
}
|
||||
|
||||
@ -9,10 +9,6 @@ func Iif[T any](c bool, t, f T) T {
|
||||
return f
|
||||
}
|
||||
|
||||
func Eol[T any](_ T, err error) error { return err }
|
||||
|
||||
func Eon[T any, E any](v T, _ E) T { return v }
|
||||
|
||||
func First[T any](v []T) T { return FirstOr(v) }
|
||||
func FirstOr[T any](v []T, def ...T) T {
|
||||
if len(v) > 0 {
|
||||
@ -31,3 +27,6 @@ func HasPrefix(s, prefix string, ignoreCase ...bool) bool {
|
||||
}
|
||||
return strings.HasPrefix(s, prefix)
|
||||
}
|
||||
|
||||
func Eol[T any](_ T, err error) error { return err }
|
||||
func Eon[T any, E any](v T, _ E) T { return v }
|
||||
|
||||
239
pkg/web/web.go
Normal file
239
pkg/web/web.go
Normal file
@ -0,0 +1,239 @@
|
||||
// Package web provides a simple HTTP multiplexer with middleware support.
|
||||
package web
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/cnk3x/xunlei/pkg/utils"
|
||||
)
|
||||
|
||||
// Processor 定义中间件处理器函数类型,接收下一个处理器并返回包装后的处理器
|
||||
type Processor func(next http.Handler) http.Handler
|
||||
|
||||
// Mux 表示一个HTTP请求多路复用器,支持中间件处理链
|
||||
type Mux struct {
|
||||
cur *http.ServeMux // 当前多路复用器
|
||||
parent *Mux // 父级多路复用器,用于嵌套结构
|
||||
processors []Processor // 处理器链(中间件)
|
||||
onShutdown func() // 关闭时执行的回调函数
|
||||
}
|
||||
|
||||
// Router 接口定义路由的基本操作
|
||||
type Router interface {
|
||||
Handle(pattern string, handler http.Handler)
|
||||
}
|
||||
|
||||
// NewMux 创建一个新的Mux实例,默认包含Recoverer中间件
|
||||
func NewMux() *Mux {
|
||||
return &Mux{cur: http.NewServeMux()}
|
||||
}
|
||||
|
||||
// OnShutDown 设置服务器关闭时的回调函数
|
||||
// - fn: 服务器关闭时执行的函数
|
||||
func (mux *Mux) OnShutDown(fn func()) { mux.onShutdown = fn }
|
||||
|
||||
// With 创建一个新的Mux实例,继承当前实例的父级和处理器,并可以添加新的处理器
|
||||
// - processor: 要添加的处理器列表
|
||||
//
|
||||
// 返回一个新的Mux实例,其父级指向当前实例
|
||||
func (mux *Mux) With(processor ...Processor) *Mux { return &Mux{parent: mux} }
|
||||
|
||||
func (mux *Mux) BasicAuth(user, pwd string) *Mux { return mux.With(BasicAuth(user, pwd)) }
|
||||
|
||||
// Use 向当前mux添加中间件处理器到处理器链中
|
||||
// - processor: 要添加的处理器列表
|
||||
func (mux *Mux) Use(processor ...Processor) { mux.processors = append(mux.processors, processor...) }
|
||||
|
||||
func (mux *Mux) UseBasicAuth(user, pwd string) { mux.Use(BasicAuth(user, pwd)) }
|
||||
|
||||
func (mux *Mux) UseRecoverer() { mux.Use(Recoverer) }
|
||||
|
||||
// Handle 注册HTTP处理器,根据是否有父级mux来决定注册位置
|
||||
// - pattern: 请求路径模式
|
||||
// - handler: HTTP处理器
|
||||
// - processors: 额外的处理器(中间件)
|
||||
func (mux *Mux) Handle(pattern string, handler http.Handler) {
|
||||
if mux.parent != nil {
|
||||
mux.parent.Handle(pattern, applyProcessors(handler, mux.processors...))
|
||||
} else {
|
||||
mux.cur.Handle(pattern, applyProcessors(handler, mux.processors...))
|
||||
}
|
||||
}
|
||||
|
||||
// Route 注册一个带有前缀模式和处理器的路由,允许通配符匹配。
|
||||
// 如果前缀不以'*'结尾,则根据情况添加'*'或'/*'以实现路径前缀匹配。
|
||||
// - prefix: 要匹配的路由前缀(可选尾随'*')
|
||||
// - handler: 路由匹配时执行的HTTP处理器
|
||||
func (mux *Mux) Route(prefix string, handler http.Handler) {
|
||||
if !strings.HasSuffix(prefix, "*") {
|
||||
if strings.HasSuffix(prefix, "/") {
|
||||
prefix += "*"
|
||||
} else {
|
||||
prefix += "/*"
|
||||
}
|
||||
}
|
||||
mux.Handle(prefix, handler)
|
||||
}
|
||||
|
||||
// Get 注册GET请求处理器
|
||||
// - pattern: GET请求路径模式
|
||||
// - handler: HTTP处理器
|
||||
// - processors: 额外的处理器(中间件)
|
||||
func (mux *Mux) Get(pattern string, handler http.Handler) {
|
||||
mux.Handle("GET "+pattern, handler)
|
||||
}
|
||||
|
||||
// Post 注册POST请求处理器
|
||||
// - pattern: POST请求路径模式
|
||||
// - handler: HTTP处理器
|
||||
// - processors: 额外的处理器(中间件)
|
||||
func (mux *Mux) Post(pattern string, handler http.Handler) {
|
||||
mux.Handle("POST "+pattern, handler)
|
||||
}
|
||||
|
||||
// ServeHTTP 实现http.Handler接口,处理HTTP请求
|
||||
func (mux *Mux) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
cmp.Or[http.Handler](mux.parent, mux.cur).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// Run 启动HTTP服务器并监听指定地址
|
||||
// - ctx: 上下文对象
|
||||
// - addr: 监听地址
|
||||
//
|
||||
// 返回启动错误(如果有的话)
|
||||
func (mux *Mux) Run(ctx context.Context, addr string) (err error) {
|
||||
s := &http.Server{
|
||||
Addr: addr, Handler: cmp.Or(mux.parent, mux),
|
||||
BaseContext: func(l net.Listener) context.Context {
|
||||
slog.InfoContext(ctx, "web started", "listen", l.Addr().String())
|
||||
return ctx
|
||||
},
|
||||
}
|
||||
|
||||
if err = s.ListenAndServe(); errors.Is(err, http.ErrServerClosed) {
|
||||
err = nil
|
||||
}
|
||||
|
||||
if mux.onShutdown != nil {
|
||||
mux.onShutdown()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Start 在后台启动HTTP服务器
|
||||
// - ctx: 上下文对象
|
||||
// - addr: 监听地址
|
||||
//
|
||||
// 返回完成通道,当服务器停止时该通道会被关闭
|
||||
func (mux *Mux) Start(ctx context.Context, addr string) (done <-chan struct{}) {
|
||||
webDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(webDone)
|
||||
if err := mux.Run(ctx, addr); err != nil {
|
||||
slog.ErrorContext(ctx, "web is done!", "err", err)
|
||||
} else {
|
||||
slog.InfoContext(ctx, "web is done!")
|
||||
}
|
||||
}()
|
||||
return webDone
|
||||
}
|
||||
|
||||
// Redirect 创建重定向处理器
|
||||
// - to: 重定向目标URL
|
||||
// - permanent: 是否永久重定向,默认临时重定向
|
||||
//
|
||||
// 返回HTTP处理器函数
|
||||
func Redirect(to string, permanent ...bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, to, utils.Iif(cmp.Or(permanent...), http.StatusPermanentRedirect, http.StatusTemporaryRedirect))
|
||||
}
|
||||
}
|
||||
|
||||
// Blob 创建返回字节切片或字符串内容的处理器
|
||||
// - body: 响应体内容
|
||||
// - contentType: 内容类型
|
||||
// - status: HTTP状态码
|
||||
//
|
||||
// 返回HTTP处理器函数
|
||||
func Blob[T ~[]byte | ~string](body T, contentType string, status int) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(body))
|
||||
}
|
||||
}
|
||||
|
||||
// Recoverer 中间件,从panic中恢复并记录日志
|
||||
func Recoverer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rvr := recover(); rvr != nil {
|
||||
if rvr == http.ErrAbortHandler {
|
||||
slog.DebugContext(r.Context(), "abort")
|
||||
// panic(rvr)
|
||||
}
|
||||
if r.Header.Get("Connection") != "Upgrade" {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// BasicAuth 创建基础认证中间件
|
||||
// - username: 用户名
|
||||
// - password: 密码
|
||||
//
|
||||
// 返回认证处理器
|
||||
func BasicAuth(username, password string) Processor {
|
||||
if password != "" {
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != username || pass != password {
|
||||
w.Header().Add("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, "xlp"))
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
return func(next http.Handler) http.Handler { return next }
|
||||
}
|
||||
|
||||
// Address 组合IP地址和端口为网络地址字符串
|
||||
// - ip: IP地址
|
||||
// - port: 端口号
|
||||
//
|
||||
// 返回组合的网络地址字符串
|
||||
func Address[T utils.UintT | utils.IntT](ip net.IP, port T) string {
|
||||
sIp := ip.String()
|
||||
return net.JoinHostPort(utils.Iif(sIp == "<nil>", "", sIp), utils.String(port))
|
||||
}
|
||||
|
||||
// applyProcessors 按逆序应用处理器链
|
||||
// - h: 初始处理器
|
||||
// - mw: 处理器列表
|
||||
//
|
||||
// 返回包装后的处理器
|
||||
func applyProcessors(h http.Handler, mw ...Processor) http.Handler {
|
||||
for _, m := range slices.Backward(mw) {
|
||||
if m != nil {
|
||||
h = m(h)
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
16
spk/spk.go
16
spk/spk.go
@ -16,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
// Extract 从迅雷SPK中提取需要的文件(存在则跳过)
|
||||
func Extract(ctx context.Context, src io.Reader, dstDir string, overwrite bool) (err error) {
|
||||
func Extract(ctx context.Context, src io.Reader, dstDir string) (err error) {
|
||||
return Walk(ctx, src, func(tr io.Reader, h *tar.Header) (err error) {
|
||||
if h.Name == "package.tgz" {
|
||||
err = cmp.Or(Walk(ctx, tr, func(tr io.Reader, h *tar.Header) (err error) {
|
||||
@ -25,26 +25,20 @@ func Extract(ctx context.Context, src io.Reader, dstDir string, overwrite bool)
|
||||
case strings.HasPrefix(h.Name, "bin/bin/version"):
|
||||
perm = 0o666
|
||||
case strings.HasPrefix(h.Name, "bin/bin/xunlei-pan-cli"):
|
||||
perm = fs.ModePerm
|
||||
perm = 0o777
|
||||
case h.Name == "ui/index.cgi":
|
||||
perm = fs.ModePerm
|
||||
perm = 0o777
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
err = func() (err error) {
|
||||
target := filepath.Join(dstDir, h.Name)
|
||||
if err = os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
if err = os.MkdirAll(filepath.Dir(target), 0o777); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
flag := os.O_RDWR | os.O_CREATE
|
||||
if overwrite {
|
||||
flag |= os.O_TRUNC
|
||||
} else {
|
||||
flag |= os.O_EXCL
|
||||
}
|
||||
f, e := os.OpenFile(target, flag, perm)
|
||||
f, e := os.OpenFile(target, os.O_RDWR|os.O_CREATE|os.O_TRUNC, perm)
|
||||
if e != nil {
|
||||
if !os.IsExist(e) {
|
||||
err = e
|
||||
|
||||
21
spk/url.go
21
spk/url.go
@ -2,6 +2,7 @@ package spk
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@ -37,15 +38,15 @@ func Download(ctx context.Context, spkUrl string, dir string, force bool) (err e
|
||||
}
|
||||
|
||||
func download_file(ctx context.Context, spkUrl string, dir string) (err error) {
|
||||
spkUrl = strings.TrimPrefix(spkUrl, "file://")
|
||||
slog.InfoContext(ctx, "download spk file", "url", spkUrl)
|
||||
spkUrl = strings.TrimSuffix(spkUrl, "file://")
|
||||
f, e := os.Open(spkUrl)
|
||||
if err = e; err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
err = Extract(ctx, f, dir, true)
|
||||
err = Extract(ctx, f, dir)
|
||||
return
|
||||
}
|
||||
|
||||
@ -55,17 +56,23 @@ func download_http(ctx context.Context, spkUrl string, dir string) (err error) {
|
||||
if req, err = http.NewRequestWithContext(ctx, http.MethodGet, spkUrl, nil); err != nil {
|
||||
return
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0")
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
|
||||
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,zh-TW;q=0.5")
|
||||
req.Header.Set("accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7")
|
||||
req.Header.Set("accept-encoding", "gzip, deflate, br, zstd")
|
||||
req.Header.Set("accept-language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6,zh-TW;q=0.5")
|
||||
req.Header.Set("cache-control", "no-cache")
|
||||
req.Header.Set("dnt", "1")
|
||||
req.Header.Set("pragma", "no-cache")
|
||||
req.Header.Set("priority", "u=0, i")
|
||||
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0")
|
||||
|
||||
cli := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}}
|
||||
var resp *http.Response
|
||||
if resp, err = http.DefaultClient.Do(req); err != nil {
|
||||
if resp, err = cli.Do(req); err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
err = Extract(ctx, resp.Body, dir, true)
|
||||
err = Extract(ctx, resp.Body, dir)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -5,20 +5,21 @@ LABEL org.opencontainers.image.authors=cnk3x
|
||||
LABEL org.opencontainers.image.source=https://github.com/cnk3x/xunlei
|
||||
|
||||
RUN apt update && apt install --no-install-recommends -y ca-certificates tzdata && rm -rf /var/lib/apt/lists/* && \
|
||||
rm -f /etc/localtime /etc/timezone && \
|
||||
cp -Lr /usr/share/zoneinfo/Asia/Chongqing /etc/localtime && \
|
||||
echo "Asia/Chongqing" >/etc/timezone
|
||||
rm -f /etc/localtime /etc/timezone && \
|
||||
cp -Lr /usr/share/zoneinfo/Asia/Chongqing /etc/localtime && \
|
||||
echo "Asia/Chongqing" >/etc/timezone
|
||||
|
||||
COPY bin/xlp-${TARGETARCH} /xlp
|
||||
COPY artifacts/xlp-${TARGETARCH} /xlp
|
||||
|
||||
ENV XL_DASHBOARD_PORT=2345 \
|
||||
XL_DASHBOARD_IP= \
|
||||
XL_DASHBOARD_USERNAME= \
|
||||
XL_DIR_DOWNLOAD=/xunlei/downloads \
|
||||
XL_PREVENT_UPDATE= \
|
||||
XL_SPK_URL= \
|
||||
XL_UID= \
|
||||
XL_GID= \
|
||||
XL_DEBUG=
|
||||
XL_DASHBOARD_IP= \
|
||||
XL_DASHBOARD_USERNAME= \
|
||||
XL_DIR_DOWNLOAD=/xunlei/downloads \
|
||||
XL_PREVENT_UPDATE= \
|
||||
XL_SPK_URL= \
|
||||
XL_UID= \
|
||||
XL_GID= \
|
||||
XL_DEBUG=
|
||||
|
||||
CMD [ "/xlp" ]
|
||||
|
||||
|
||||
72
web.go
72
web.go
@ -1,72 +0,0 @@
|
||||
package xunlei
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/cnk3x/xunlei/pkg/utils"
|
||||
)
|
||||
|
||||
func webRedirect(to string, permanent ...bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, to, utils.Iif(cmp.Or(permanent...), http.StatusPermanentRedirect, http.StatusTemporaryRedirect))
|
||||
}
|
||||
}
|
||||
|
||||
func webBlob[T ~[]byte | ~string](body T, contentType string, status int) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(body))
|
||||
}
|
||||
}
|
||||
|
||||
type webMw = func(next http.Handler) http.Handler
|
||||
|
||||
func webRecoverer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if rvr := recover(); rvr != nil {
|
||||
if rvr == http.ErrAbortHandler {
|
||||
panic(rvr)
|
||||
}
|
||||
if r.Header.Get("Connection") != "Upgrade" {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// func webChain(h http.Handler, mws ...webMw) http.Handler {
|
||||
// for _, mw := range slices.Backward(mws) {
|
||||
// h = mw(h)
|
||||
// }
|
||||
// return h
|
||||
// }
|
||||
|
||||
// func webHandle(mux *http.ServeMux, pattern string, h http.Handler, mws ...webMw) {
|
||||
// mux.Handle(pattern, webChain(h, slices.Insert(mws, 0, webRecoverer)...))
|
||||
// }
|
||||
|
||||
func webBasicAuth(username, password string) webMw {
|
||||
if password != "" {
|
||||
if username == "" {
|
||||
username = "admin"
|
||||
}
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != username || pass != password {
|
||||
w.Header().Add("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, "xlp"))
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
return func(next http.Handler) http.Handler { return next }
|
||||
}
|
||||
71
xlp.go
71
xlp.go
@ -6,13 +6,11 @@ import (
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cgi"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
@ -20,7 +18,7 @@ import (
|
||||
"github.com/cnk3x/xunlei/pkg/fo"
|
||||
"github.com/cnk3x/xunlei/pkg/log"
|
||||
"github.com/cnk3x/xunlei/pkg/utils"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/cnk3x/xunlei/pkg/web"
|
||||
)
|
||||
|
||||
const Version = "3.21"
|
||||
@ -46,6 +44,7 @@ const (
|
||||
PATH_SYNO_INFO_CONF = "/etc/synoinfo.conf" //synoinfo.conf 文件路径
|
||||
PATH_SYNO_AUTHENTICATE_CGI = "/usr/syno/synoman/webman/modules/authenticate.cgi" //syno...authenticate.cgi 文件路径
|
||||
UPDATE_URL = "/webman/3rdparty/" + SYNOPKG_PKGNAME + "/version"
|
||||
CGI_URL = "/webman/3rdparty/" + SYNOPKG_PKGNAME + "/index.cgi/"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -108,9 +107,7 @@ func Run(ctx context.Context, cfg Config) (err error) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
defer func() {
|
||||
os.RemoveAll(VAR_DIR)
|
||||
}()
|
||||
defer os.RemoveAll(VAR_DIR)
|
||||
|
||||
var dirDownload []string
|
||||
if dirDownload, err = utils.NewRootPath(cfg.Chroot, cfg.DirDownload...); err != nil {
|
||||
@ -130,12 +127,11 @@ func Run(ctx context.Context, cfg Config) (err error) {
|
||||
|
||||
envs := mockEnv(dirDownload, dirData[0])
|
||||
|
||||
var webDone <-chan struct{}
|
||||
if webDone, err = mockWeb(ctx, envs, cfg); err != nil {
|
||||
webDone, e := mockWeb(ctx, envs, cfg)
|
||||
if err = e; err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
go utils.SelectDo(webDone, cancel, ctx.Done())
|
||||
utils.After(webDone, cancel)
|
||||
|
||||
slog.DebugContext(ctx, "app start")
|
||||
args := []string{"-launcher_listen", "unix://" + LAUNCHER_LISTEN_PATH, "-pid", PID_FILE}
|
||||
@ -153,60 +149,27 @@ func Run(ctx context.Context, cfg Config) (err error) {
|
||||
|
||||
func mockWeb(ctx context.Context, env []string, cfg Config) (webDone <-chan struct{}, err error) {
|
||||
ctx = log.Prefix(ctx, "mock")
|
||||
mux := chi.NewMux()
|
||||
mux.Use(webRecoverer)
|
||||
mux := web.NewMux()
|
||||
mux.UseRecoverer()
|
||||
|
||||
console := wrapConsole(ctx)
|
||||
hCgi := &cgi.Handler{
|
||||
Dir: fmt.Sprintf("%s/bin", SYNOPKG_PKGDEST),
|
||||
Path: fmt.Sprintf("%s/ui/index.cgi", SYNOPKG_PKGDEST),
|
||||
Env: env,
|
||||
Logger: log.Std(console, "wcgi"),
|
||||
Logger: utils.LogStd(console),
|
||||
Stderr: console,
|
||||
}
|
||||
|
||||
indexPattern := fmt.Sprintf("/webman/3rdparty/%s/index.cgi/", SYNOPKG_PKGNAME)
|
||||
mux.With(webBasicAuth(cfg.DashboardUsername, cfg.DashboardPassword)).Handle(indexPattern+"*", hCgi)
|
||||
mux.Handle("/", web.Redirect(CGI_URL, true))
|
||||
mux.Handle("/web", web.Redirect(CGI_URL, true))
|
||||
mux.Handle("/webman", web.Redirect(CGI_URL, true))
|
||||
mux.Handle(UPDATE_URL, web.Blob(fmt.Sprintf("arch: %s\nversion: \"0.0.1\"\naccept: [\"9.9.9\"]", runtime.GOARCH), `text/vnd.yaml`, 200))
|
||||
mux.Handle("/webman/login.cgi", web.Blob(fmt.Sprintf(`{"SynoToken":%q,"result":"success","success":true}`, utils.RandText(13)), "application/json", http.StatusOK))
|
||||
mux.BasicAuth(cfg.DashboardUsername, cfg.DashboardPassword).Route(CGI_URL, hCgi)
|
||||
|
||||
mux.Handle("GET /", webRedirect(indexPattern, true))
|
||||
mux.Handle("GET /web", webRedirect(indexPattern, true))
|
||||
mux.Handle("GET /webman", webRedirect(indexPattern, true))
|
||||
mux.Handle("/webman/login.cgi", webBlob(fmt.Sprintf(`{"SynoToken":%q,"result":"success","success":true}`, utils.RandText(13)), "application/json", http.StatusOK))
|
||||
mux.HandleFunc("GET "+UPDATE_URL, func(w http.ResponseWriter, r *http.Request) {
|
||||
webBlob(fmt.Sprintf("arch: %s\nversion: \"0.0.1\"\naccept: [\"9.9.9\"]", runtime.GOARCH), `text/vnd.yaml`, 200)
|
||||
})
|
||||
|
||||
ip := cfg.Ip.String()
|
||||
if ip == "<nil>" {
|
||||
ip = ""
|
||||
}
|
||||
|
||||
err = func() (err error) {
|
||||
s := &http.Server{Addr: net.JoinHostPort(ip, strconv.FormatUint(uint64(cfg.Port), 10)), Handler: mux}
|
||||
|
||||
s.BaseContext = func(l net.Listener) context.Context {
|
||||
slog.InfoContext(ctx, "ui started", "listen", l.Addr().String())
|
||||
return ctx
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
webDone = done
|
||||
|
||||
go func() {
|
||||
defer close(done)
|
||||
defer console.Close()
|
||||
|
||||
if err := s.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.ErrorContext(ctx, "ui is done!", "err", err)
|
||||
} else {
|
||||
slog.InfoContext(ctx, "ui is done!")
|
||||
}
|
||||
}()
|
||||
|
||||
go utils.SelectDo(ctx.Done(), func() { s.Shutdown(context.Background()) }, done)
|
||||
|
||||
return
|
||||
}()
|
||||
webDone = mux.Start(ctx, web.Address(cfg.Ip, cfg.Port))
|
||||
utils.After(webDone, utils.Fne(console.Close))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user