From 2d396d3789419be5efae436615877c745d39cb93 Mon Sep 17 00:00:00 2001 From: Xboard Date: Sat, 2 Nov 2024 04:49:43 +0800 Subject: [PATCH] v1 --- .github/workflows/docker.yml | 99 ++++++++++++++++++++++--------- Dockerfile | 7 ++- README.md | 95 +++++++++++++----------------- app/cmd/server.go | 90 ++++++++++++++++++++++++++++ core/server/config.go | 2 + entrypoint | 50 ++++++++++++++++ extras/auth/v2board.go | 110 +++++++++++++++++++++++++++++++++++ extras/trafficlogger/http.go | 63 ++++++++++++++++++++ 8 files changed, 433 insertions(+), 83 deletions(-) create mode 100644 entrypoint create mode 100644 extras/auth/v2board.go diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index daa0075..c32d536 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,44 +1,89 @@ -name: "Build Docker Image" +name: Docker + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. on: push: - tags: - - app/v*.*.* + branches: [ "master" ] + # Publish semver tags as releases. + tags: [ 'v*.*.*' ] + +env: + # Use docker.io for Docker Hub if empty + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + jobs: - docker: + build: + runs-on: ubuntu-latest - env: - ACTIONS_ALLOW_UNSECURE_COMMANDS: true + permissions: + contents: read + packages: write + # This is used to complete the identity challenge + # with sigstore/fulcio when running outside of PRs. + id-token: write steps: - - name: Check out - uses: actions/checkout@v4 - - - name: Get version - id: get_version - run: echo "version=$(git describe --tags --always --match 'app/v*' | sed -n 's|app/\([^/-]*\)\(-.*\)\{0,1\}|\1|p')" >> $GITHUB_OUTPUT + - name: Checkout repository + uses: actions/checkout@v3 - name: Set up QEMU uses: docker/setup-qemu-action@v3 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login to DockerHub - uses: docker/login-action@v3 + - name: Install cosign + uses: sigstore/cosign-installer@v3.4.0 with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} + cosign-release: 'v2.2.2' - - name: Build and push - id: docker_build - uses: docker/build-push-action@v5 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3.2.0 + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + uses: docker/login-action@v3.1.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5.5.1 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + + - name: Get version + id: get_version + run: echo "version=$(git describe --tags --always)" >> $GITHUB_OUTPUT + + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@v5.3.0 with: context: . push: true - platforms: linux/amd64,linux/arm64 - tags: tobyxdd/hysteria:latest,tobyxdd/hysteria:v2,tobyxdd/hysteria:${{ steps.get_version.outputs.version }} - - - name: Image digest - run: echo ${{ steps.docker_build.outputs.digest }} + platforms: linux/amd64 + tags: | + ${{ env.REGISTRY }}/${{ github.repository_owner }}/hysteria + ${{ env.REGISTRY }}/${{ github.repository_owner }}/hysteria:latest + ${{ env.REGISTRY }}/${{ github.repository_owner }}/hysteria:${{ steps.get_version.outputs.version }} + # Sign the resulting Docker image digest except on PRs. + # This will only write to the public Rekor transparency log when the Docker + # repository is public to avoid leaking data. If you would like to publish + # transparency data even for private images, pass --force to cosign below. + # https://github.com/sigstore/cosign + - name: Sign the published Docker image + env: + # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build-and-push.outputs.digest }} + # This step uses the identity token to provision an ephemeral certificate + # against the sigstore community Fulcio instance. + run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e49ad63..d4c48a9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,11 +29,12 @@ RUN if [ ! -e /etc/nsswitch.conf ]; then echo 'hosts: files dns' > /etc/nsswitch # # Do not try to add the "--no-cache" option when there are multiple "apk" # commands, this will cause the build process to become very slow. +COPY ./entrypoint /usr/local/bin/entrypoint RUN set -ex \ && apk upgrade \ && apk add bash tzdata ca-certificates \ - && rm -rf /var/cache/apk/* + && rm -rf /var/cache/apk/* \ + && chmod +x /usr/local/bin/entrypoint COPY --from=builder /go/bin/hysteria /usr/local/bin/hysteria - -ENTRYPOINT ["hysteria"] \ No newline at end of file +CMD ["entrypoint"] \ No newline at end of file diff --git a/README.md b/README.md index 94fa335..033aa00 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,49 @@ # ![Hysteria 2](logo.svg) -[![License][1]][2] [![Release][3]][4] [![Telegram][5]][6] [![Discussions][7]][8] +# 支持对接V2board面板的Hysteria2后端 -[1]: https://img.shields.io/badge/license-MIT-blue -[2]: LICENSE.md -[3]: https://img.shields.io/github/v/release/apernet/hysteria?style=flat-square -[4]: https://github.com/apernet/hysteria/releases -[5]: https://img.shields.io/badge/chat-Telegram-blue?style=flat-square -[6]: https://t.me/hysteria_github -[7]: https://img.shields.io/github/discussions/apernet/hysteria?style=flat-square -[8]: https://github.com/apernet/hysteria/discussions +### 项目说明 +本项目基于hysteria官方内核二次开发,添加了从v2b获取节点信息、用户鉴权信息与上报用户流量的功能。 +性能方面已经由hysteria2内核作者亲自指导优化过了。 -

Hysteria is a powerful, lightning fast and censorship resistant proxy.

+### TG交流群 +欢迎加入交流群 [点击加入](https://t.me/+DcRt8AB2VbI2Yzc1) -### [Get Started](https://v2.hysteria.network/) -### [中文文档](https://v2.hysteria.network/zh/) +### 示例配置 +``` +v2board: + apiHost: https://面板地址 + apiKey: 面板节点密钥 + nodeID: 节点ID +tls: + type: tls + cert: /etc/hysteria/tls.crt + key: /etc/hysteria/tls.key +auth: + type: v2board +trafficStats: + listen: 127.0.0.1:7653 +acl: + inline: + - reject(10.0.0.0/8) + - reject(172.16.0.0/12) + - reject(192.168.0.0/16) + - reject(127.0.0.0/8) + - reject(fc00::/7) +``` +> 其他配置完全与hysteria文档的一致,可以查看hysteria2官方文档 [点击查看](https://hysteria.network/zh/docs/getting-started/Installation/) -### [Hysteria 1.x (legacy)](https://v1.hysteria.network/) - ---- - -
-
-

🛠️ Jack of all trades

-

Wide range of modes including SOCKS5, HTTP Proxy, TCP/UDP Forwarding, Linux TProxy, TUN - with more features being added constantly.

-
- -
-

⚡ Blazing fast

-

Powered by a customized QUIC protocol, Hysteria is designed to deliver unparalleled performance over unreliable and lossy networks.

-
- -
-

✊ Censorship resistant

-

The protocol masquerades as standard HTTP/3 traffic, making it very difficult for censors to detect and block without widespread collateral damage.

-
- -
-

💻 Cross-platform

-

We have builds for every major platform and architecture. Deploy anywhere & use everywhere. Not to mention the long list of 3rd party apps.

-
- -
-

🔗 Easy integration

-

With built-in support for custom authentication, traffic statistics & access control, Hysteria is easy to integrate into your infrastructure.

-
- -
-

🤗 Chill and supportive

-

We have well-documented specifications and code for developers to contribute and/or build their own apps. And a helpful community, too.

-
-
- ---- - -**If you find Hysteria useful, consider giving it a ⭐️!** - -[![Star History Chart](https://api.star-history.com/svg?repos=apernet/hysteria&type=Date)](https://star-history.com/#apernet/hysteria&Date) +### 快速启动 +``` +docker run -itd --restart=always --network=host \ + -e apiHost=https://example.com \ + -e apiKey=xxxxxxxxxxxxxxxxxxxxx \ + -e domain=hy2.example.com \ + -e nodeID=1 \ +ghcr.io/cedar2025/hysteria:latest +``` +### docker 仓库 +``` +docker pull ghcr.io/cedar2025/hysteria:latest +``` \ No newline at end of file diff --git a/app/cmd/server.go b/app/cmd/server.go index a4b8470..00e41fd 100644 --- a/app/cmd/server.go +++ b/app/cmd/server.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net" "net/http" "net/http/httputil" @@ -54,6 +55,7 @@ func init() { } type serverConfig struct { + V2board *v2boardConfig `mapstructure:"v2board"` Listen string `mapstructure:"listen"` Obfs serverConfigObfs `mapstructure:"obfs"` TLS *serverConfigTLS `mapstructure:"tls"` @@ -73,6 +75,14 @@ type serverConfig struct { Masquerade serverConfigMasquerade `mapstructure:"masquerade"` } +type v2boardConfig struct { + ApiHost string `mapstructure:"apiHost"` + ApiKey string `mapstructure:"apiKey"` + NodeID uint `mapstructure:"nodeID"` + PullInterval time.Duration `mapstructure:"pullInterval"` + PushInterval time.Duration `mapstructure:"pushInterval"` +} + type serverConfigObfsSalamander struct { Password string `mapstructure:"password"` } @@ -764,6 +774,14 @@ func (c *serverConfig) fillAuthenticator(hyConfig *server.Config) error { return configError{Field: "auth.command", Err: errors.New("empty auth command")} } hyConfig.Authenticator = &auth.CommandAuthenticator{Cmd: c.Auth.Command} + return nil + case "v2board": + v2boardConfig := c.V2board + if v2boardConfig.ApiHost == "" || v2boardConfig.ApiKey == "" || v2boardConfig.NodeID == 0 { + return configError{Field: "auth.v2board", Err: errors.New("v2board config error")} + } + hyConfig.Authenticator = &auth.V2boardApiProvider{URL: fmt.Sprintf("%s?token=%s&node_id=%d&node_type=hysteria", c.V2board.ApiHost+"/api/v1/server/UniProxy/user", c.V2board.ApiKey, c.V2board.NodeID)} + return nil default: return configError{Field: "auth.type", Err: errors.New("unsupported auth type")} @@ -776,11 +794,28 @@ func (c *serverConfig) fillEventLogger(hyConfig *server.Config) error { } func (c *serverConfig) fillTrafficLogger(hyConfig *server.Config) error { + pullInterval := time.Second * 5 + if c.V2board.PullInterval > 0 { + pullInterval = time.Duration(c.V2board.PullInterval) * time.Second + } + pushInterval := time.Second * 60 + if c.V2board.PushInterval > 0 { + pushInterval = time.Duration(c.V2board.PushInterval) * time.Second + } + userURL := fmt.Sprintf("%s?token=%s&node_id=%d&node_type=hysteria", c.V2board.ApiHost+"/api/v1/server/UniProxy/user", c.V2board.ApiKey, c.V2board.NodeID) + pushURL := fmt.Sprintf("%s?token=%s&node_id=%d&node_type=hysteria", c.V2board.ApiHost+"/api/v1/server/UniProxy/push", c.V2board.ApiKey, c.V2board.NodeID) if c.TrafficStats.Listen != "" { tss := trafficlogger.NewTrafficStatsServer(c.TrafficStats.Secret) hyConfig.TrafficLogger = tss + if c.V2board != nil && c.V2board.ApiHost != "" { + go auth.UpdateUsers(userURL, pullInterval, hyConfig.TrafficLogger) + go hyConfig.TrafficLogger.PushTrafficToV2boardInterval(pushURL, pushInterval) + } go runTrafficStatsServer(c.TrafficStats.Listen, tss) + } else { + go auth.UpdateUsers(userURL, pullInterval, nil) } + return nil } @@ -894,6 +929,19 @@ func (c *serverConfig) Config() (*server.Config, error) { return hyConfig, nil } +type ResponseNodeInfo struct { + Host string `json:"host"` + ServerPort uint `json:"server_port"` + ServerName string `json:"server_name"` + UpMbps uint `json:"down_mbps"` + DownMbps uint `json:"up_mbps"` + Obfs string `json:"obfs"` + BaseConfig struct { + PushInterval int `json:"push_interval"` + PullInterval int `json:"pull_interval"` + } `json:"base_config"` +} + func runServer(cmd *cobra.Command, args []string) { logger.Info("server mode") @@ -904,6 +952,48 @@ func runServer(cmd *cobra.Command, args []string) { if err := viper.Unmarshal(&config); err != nil { logger.Fatal("failed to parse server config", zap.Error(err)) } + // 如果配置了v2board 则自动获取监听端口、obfs + if config.V2board != nil && config.V2board.ApiHost != "" { + // 创建一个url.Values来存储查询参数 + queryParams := url.Values{ + "token": {config.V2board.ApiKey}, + "node_id": {strconv.Itoa(int(config.V2board.NodeID))}, + "node_type": {"hysteria"}, + } + nodeInfoUrl := config.V2board.ApiHost + "/api/v1/server/UniProxy/config?" + queryParams.Encode() + resp, err := http.Get(nodeInfoUrl) + if err != nil { + // 处理错误 + fmt.Println("HTTP GET 请求出错:", err) + logger.Fatal("failed to client v2board api to get nodeInfo", zap.Error(err)) + } + defer resp.Body.Close() + // 读取响应数据 + body, err := io.ReadAll(resp.Body) + if err != nil { + logger.Fatal("failed to read v2board reaponse", zap.Error(err)) + } + // 解析JSON数据 + var responseNodeInfo ResponseNodeInfo + err = json.Unmarshal(body, &responseNodeInfo) + if err != nil { + logger.Fatal("failed to unmarshal v2board reaponse", zap.Error(err)) + } + // 给 hy的端口、obfs、上行下行进行赋值 + if responseNodeInfo.ServerPort != 0 { + config.Listen = ":" + strconv.Itoa(int(responseNodeInfo.ServerPort)) + } + if responseNodeInfo.DownMbps != 0 { + config.Bandwidth.Down = strconv.Itoa(int(responseNodeInfo.DownMbps)) + "Mbps" + } + if responseNodeInfo.UpMbps != 0 { + config.Bandwidth.Up = strconv.Itoa(int(responseNodeInfo.UpMbps)) + "Mbps" + } + if responseNodeInfo.Obfs != "" { + config.Obfs.Type = "salamander" + config.Obfs.Salamander.Password = responseNodeInfo.Obfs + } + } hyConfig, err := config.Config() if err != nil { logger.Fatal("failed to load server config", zap.Error(err)) diff --git a/core/server/config.go b/core/server/config.go index f90c820..771dbab 100644 --- a/core/server/config.go +++ b/core/server/config.go @@ -211,5 +211,7 @@ type EventLogger interface { // The implementation of this interface must be thread-safe. type TrafficLogger interface { LogTraffic(id string, tx, rx uint64) (ok bool) + PushTrafficToV2boardInterval(url string, interval time.Duration) LogOnlineState(id string, online bool) + NewKick(id string) (ok bool) } diff --git a/entrypoint b/entrypoint new file mode 100644 index 0000000..d9de8ec --- /dev/null +++ b/entrypoint @@ -0,0 +1,50 @@ +#!/bin/sh + +CONFIG_FILE="/etc/hysteria/server.yaml" + +# 判断配置文件是否存存在,如果不存在走不存在的逻辑 +if [ ! -f "$CONFIG_FILE" ]; then + echo "Creating configuration file $CONFIG_FILE" + mkdir -p /etc/hysteria + cat <"$CONFIG_FILE" +v2board: + apiHost: ${apiHost} + apiKey: ${apiKey} + nodeID: ${nodeID} +acme: + domains: + - ${domain} + email: your@email.com +auth: + type: v2board +trafficStats: + listen: 127.0.0.1:7653 +acl: + inline: + - reject(10.0.0.0/8) + - reject(172.16.0.0/12) + - reject(192.168.0.0/16) + - reject(127.0.0.0/8) + - reject(fc00::/7) +EOF +fi + +hysteria server -c $CONFIG_FILE 2>&1 | tee & + +# 获取HYSTERIA server命令的进程组ID(Process Group ID) +HYSTERIA_PID=$! + +# 定义一个函数来处理Ctrl+C信号 +cleanup() { + echo "接收到Ctrl+C信号,正在停止HYSTERIA..." + # 向HYSTERIA进程组发送终止信号 + kill -SIGINT $HYSTERIA_PID + exit 0 +} + +# 捕获Ctrl+C信号并调用cleanup函数 +trap cleanup INT +trap cleanup SIGTERM + +# 等待HYSTERIA进程结束 +wait \ No newline at end of file diff --git a/extras/auth/v2board.go b/extras/auth/v2board.go new file mode 100644 index 0000000..0652709 --- /dev/null +++ b/extras/auth/v2board.go @@ -0,0 +1,110 @@ +package auth + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "strconv" + "sync" + "time" + + "github.com/apernet/hysteria/core/v2/server" +) + +var _ server.Authenticator = &V2boardApiProvider{} + +type V2boardApiProvider struct { + Client *http.Client + URL string +} + +// 用户列表 +var ( + usersMap map[string]User + lock sync.Mutex +) + +type User struct { + ID int `json:"id"` + UUID string `json:"uuid"` + SpeedLimit *uint32 `json:"speed_limit"` +} +type ResponseData struct { + Users []User `json:"users"` +} + +func getUserList(url string) ([]User, error) { + resp, err := http.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var responseData ResponseData + err = json.NewDecoder(resp.Body).Decode(&responseData) + if err != nil { + return nil, err + } + + return responseData.Users, nil +} + +func UpdateUsers(url string, interval time.Duration, trafficlogger server.TrafficLogger) { + fmt.Println("用户列表自动更新服务已激活,更新周期为", interval) + + // 先立即执行一次更新 + userList, err := getUserList(url) + if err != nil { + fmt.Println("Error:", err) + return // 如果第一次获取失败,退出函数 + } + processUserList(userList, trafficlogger) + + // 设置定时器 + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + userList, err := getUserList(url) + if err != nil { + fmt.Println("Error:", err) + continue + } + processUserList(userList, trafficlogger) + } +} + +// 处理用户列表的逻辑 +func processUserList(userList []User, trafficlogger server.TrafficLogger) { + lock.Lock() + defer lock.Unlock() + + newUsersMap := make(map[string]User) + for _, user := range userList { + newUsersMap[user.UUID] = user + } + if trafficlogger != nil { + for uuid := range usersMap { + if _, exists := newUsersMap[uuid]; !exists { + fmt.Println(usersMap[uuid].ID) + trafficlogger.NewKick(strconv.Itoa(usersMap[uuid].ID)) + } + } + } + + usersMap = newUsersMap +} + +// 验证代码 +func (v *V2boardApiProvider) Authenticate(addr net.Addr, auth string, tx uint64) (ok bool, id string) { + + // 获取判断连接用户是否在用户列表内 + lock.Lock() + defer lock.Unlock() + + if user, exists := usersMap[auth]; exists { + return true, strconv.Itoa(user.ID) + } + return false, "" +} diff --git a/extras/trafficlogger/http.go b/extras/trafficlogger/http.go index 9ab943a..77dedfe 100644 --- a/extras/trafficlogger/http.go +++ b/extras/trafficlogger/http.go @@ -1,10 +1,14 @@ package trafficlogger import ( + "bytes" "encoding/json" + "errors" + "fmt" "net/http" "strconv" "sync" + "time" "github.com/apernet/hysteria/core/v2/server" ) @@ -29,6 +33,57 @@ func NewTrafficStatsServer(secret string) TrafficStatsServer { } } +type TrafficPushRequest struct { + Data map[string][2]int64 +} + +// 定时提交用户流量情况 +func (s *trafficStatsServerImpl) PushTrafficToV2boardInterval(url string, interval time.Duration) { + fmt.Println("用户流量情况监控已启动,提交周期为:", interval) + + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for range ticker.C { + if err := s.PushTrafficToV2board(url); err != nil { + fmt.Println("用户流量信息提交失败:", err) + } + } + +} + +// 向v2board 提交用户流量使用情况 +func (s *trafficStatsServerImpl) PushTrafficToV2board(url string) error { + s.Mutex.Lock() // 写锁,阻止其他操作 StatsMap 的并发访问 + defer s.Mutex.Unlock() // 确保在函数退出时释放写锁 + + request := TrafficPushRequest{ + Data: make(map[string][2]int64), + } + for id, stats := range s.StatsMap { + request.Data[id] = [2]int64{int64(stats.Tx), int64(stats.Rx)} + } + if len(request.Data) == 0 { + return nil + } + jsonData, err := json.Marshal(request.Data) + if err != nil { + return err + } + resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + fmt.Println(resp) + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return errors.New("HTTP request failed with status code: " + resp.Status) + } + s.StatsMap = make(map[string]*trafficStatsEntry) + + return nil +} + type trafficStatsServerImpl struct { Mutex sync.RWMutex StatsMap map[string]*trafficStatsEntry @@ -152,3 +207,11 @@ func (s *trafficStatsServerImpl) kick(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) } + +// 踢出用户名单 +func (s *trafficStatsServerImpl) NewKick(id string) bool { + s.Mutex.Lock() + s.KickMap[id] = struct{}{} + s.Mutex.Unlock() + return true +}