This commit is contained in:
cnk3x 2026-01-15 17:09:45 +08:00
parent 99f0a5ffb0
commit e7f206adfc
35 changed files with 1005 additions and 683 deletions

1
.gitignore vendored
View File

@ -11,3 +11,4 @@ home.repo
testdata/
/amd64/
artifacts/
/lib

View File

@ -1,5 +1,6 @@
{
"go.toolsEnvVars": {
"GOOS": "linux"
}
"go.toolsEnvVars": {
"GOOS": "linux",
"CGO_ENABLED": "0"
}
}

View File

@ -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" ]

View File

@ -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 .

View File

@ -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
View File

@ -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)
})
}

View File

@ -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)
}

View File

@ -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")

View File

@ -49,6 +49,7 @@ words:
- rootfs
- shenzhen
- softprops
- succ
- SYNO
- synobios
- synoinfo

25
debian.Dockerfile Normal file
View 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
View File

@ -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
View File

@ -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=

View File

@ -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"

View File

@ -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()}
}

View File

@ -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)) }

View File

@ -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)
}
}
}

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View 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()
}
}
}
}

View File

@ -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 }

View File

@ -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
}

View File

@ -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() } }

View File

@ -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 {

View File

@ -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)
}

View File

@ -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
View 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
}

View File

@ -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

View File

@ -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
}

View File

@ -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
View File

@ -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
View File

@ -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
}