diff --git a/.env.example b/.env.example index c6c7652..e3fd97f 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ -PORT=8080 -SECRET=testing123 +APP_CTRL_PORT=18080 +APP_DATA_PORT=18081 DB_HOST=localhost DB_PORT=5432 diff --git a/Dockerfile b/Dockerfile index 4321b8f..a93a049 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ FROM ubuntu:20.04 WORKDIR /app -COPY ./bin/proxy_linux_amd64 /app/proxy +COPY ./bin/proxy_server_linux_amd64 /app/proxy RUN chmod +x /app/proxy EXPOSE $PORT diff --git a/README.md b/README.md index 6d9667f..c8c67af 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,6 @@ ## todo -docker 脚本和环境变量文件都放在 scripts 目录下 - -client 配置环境变量(或者命令行参数) - -测试流程完成后合并项目 - -考虑是否需要将 devices 表改名成 nodes +检查 ip 时需要判断同一 ip 的不同写法 ### 长期 diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..5d839c9 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,60 @@ +Remove-Job * + +$tasks = 0 +$start = Get-Date + +$tasks++ +Write-Output "building proxy server for windows amd64..." +Start-Job -ScriptBlock { + $env:GOOS = "windows"; $env:GOARCH = "amd64"; go build -ldflags '-w -s' -o bin/proxy_server_win_amd64.exe cmd/server/main.go +} | Out-Null + +$tasks++ +Write-Output "building proxy server for linux amd64..." +Start-Job -ScriptBlock { + $env:GOOS = "linux"; $env:GOARCH = "amd64"; go build -ldflags '-w -s' -o bin/proxy_server_linux_amd64 cmd/server/main.go +} | Out-Null + +$tasks++ +Write-Output "building proxy server for linux arm64..." +Start-Job -ScriptBlock { + $env:GOOS = "linux"; $env:GOARCH = "arm64"; go build -ldflags '-w -s' -o bin/proxy_server_linux_arm64 cmd/server/main.go +} | Out-Null + +$tasks++ +Write-Output "building proxy client for windows amd64..." +Start-Job -ScriptBlock { + $env:GOOS = "windows"; $env:GOARCH = "amd64"; go build -ldflags '-w -s' -o bin/proxy_client_win_amd64.exe cmd/client/main.go +} | Out-Null + +$tasks++ +Write-Output "building proxy client for linux amd64..." +Start-Job -ScriptBlock { + $env:GOOS = "linux"; $env:GOARCH = "amd64"; go build -ldflags '-w -s' -o bin/proxy_client_linux_amd64 cmd/client/main.go +} | Out-Null + +$tasks++ +Write-Output "building proxy client for linux arm64..." +Start-Job -ScriptBlock { + $env:GOOS = "linux"; $env:GOARCH = "arm64"; go build -ldflags '-w -s' -o bin/proxy_client_linux_arm64 cmd/client/main.go +} | Out-Null + +# Wait for all jobs to complete +while ($tasks -gt 0) +{ + foreach ($job in Get-Job) + { + if ($job.State -eq "Completed") + { + $tasks-- + $job | Receive-Job + $job | Remove-Job + } + } +} + +$end = Get-Date + +Write-Output "build completed" +Write-Output "time taken: $( ($end - $start).TotalSeconds ) seconds" +Write-Output "output files are in ./bin/" \ No newline at end of file diff --git a/cmd/client/main.go b/cmd/client/main.go new file mode 100644 index 0000000..ec61e82 --- /dev/null +++ b/cmd/client/main.go @@ -0,0 +1,156 @@ +package main + +import ( + "bufio" + "encoding/binary" + "github.com/joho/godotenv" + "io" + "log/slog" + "net" + "os" + "proxy-server/pkg/utils" + "strconv" +) + +func main() { + slog.SetLogLoggerLevel(slog.LevelDebug) + + // 初始化环境变量 + err := godotenv.Load() + if err != nil { + slog.Debug("没有本地环境变量文件") + } + + // 建立控制连接 + for { + slog.Info("与服务端建立控制连接") + frpHost := os.Getenv("FRP_SERVER") + frpPort := os.Getenv("FRP_PORT") + frpAddr := net.JoinHostPort(frpHost, frpPort) + slog.Info("frpAddr", frpAddr) + conn, err := net.Dial("tcp", frpAddr) + if err != nil { + slog.Error("dial error", err) + return + } + + reader := bufio.NewReader(conn) + + // 请求转发端口 + slog.Info("请求转发端口") + fwdPortStr := os.Getenv("FWD_PORT") + fwdPort, err := strconv.ParseUint(fwdPortStr, 10, 16) + if err != nil { + slog.Error("parse error", err) + return + } + portBuf := make([]byte, 2) + binary.BigEndian.PutUint16(portBuf, uint16(fwdPort)) + _, err = conn.Write(portBuf) + if err != nil { + slog.Error("write error", err) + return + } + + // 读取目标地址 + for { + slog.Info("等待建立数据通道命令") + + tagLen, err := reader.ReadByte() + if err != nil { + slog.Error("read error", err) + return + } + + tagBuf := make([]byte, tagLen) + _, err = io.ReadFull(conn, tagBuf) + if err != nil { + slog.Error("read error", err) + return + } + + // 建立数据通道 + go dataTun(tagLen, tagBuf) + } + } +} + +func dataTun(tagLen byte, tagBuff []byte) { + + slog.Info("建立数据通道") + src, err := net.Dial("tcp", "localhost:18081") + if err != nil { + slog.Error("建立数据通道失败", err) + return + } + defer func() { + err := src.Close() + if err != nil { + slog.Error("close error", err) + } + }() + + // 发送 tag + _, err = src.Write([]byte{tagLen}) + if err != nil { + slog.Error("write error", err) + return + } + _, err = src.Write(tagBuff) + if err != nil { + slog.Error("write error", err) + return + } + + // 接收目标地址 + slog.Info("等待目标地址") + + addrLen, err := utils.ReadByte(src) + if err != nil { + slog.Error("接收目标地址失败", err) + return + } + addrBuf, err := utils.ReadBuffer(src, int(addrLen)) + if err != nil { + slog.Error("接收目标地址失败", err) + return + } + addr := string(addrBuf) + + // 数据转发 + slog.Info("向 " + addr + " 建立连接") + dest, err := net.Dial("tcp", addr) + if err != nil { + slog.Error("与目标地址连接建立失败", err) + return + } + defer func() { + err = dest.Close() + if err != nil { + slog.Error("close error", err) + } + }() + + slog.Info("开始数据转发 " + src.RemoteAddr().String() + " <-> " + dest.RemoteAddr().String()) + + errCh := make(chan error, 2) + go func() { + _, err := io.Copy(src, dest) + if err != nil { + slog.Error("copy error f2t", err) + errCh <- err + return + } + errCh <- nil + }() + go func() { + _, err := io.Copy(dest, src) + if err != nil { + slog.Error("copy error t2f", err) + errCh <- err + return + } + errCh <- nil + }() + <-errCh +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..69665a2 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,396 @@ +package main + +import ( + "bufio" + "context" + "encoding/binary" + "github.com/joho/godotenv" + "github.com/pkg/errors" + "io" + "log/slog" + "net" + "os" + "proxy-server/pkg/socks5" + "proxy-server/pkg/utils" + "proxy-server/server/orm" + "proxy-server/server/web/app/models" + "strconv" + "time" +) + +var connMap = make(map[string]socks5.ProxyData) + +func main() { + slog.SetLogLoggerLevel(slog.LevelDebug) + + // 初始化环境变量 + err := godotenv.Load() + if err != nil { + slog.Debug("没有本地环境变量文件") + } + + orm.Init() + + go startControlTun() + go startDataTun() + select {} +} + +func startDataTun() { + dataPort := os.Getenv("APP_DATA_PORT") + if dataPort == "" { + panic("环境变量 APP_DATA_PORT 未设置") + } + + slog.Info("监听数据端口 " + dataPort) + lData, err := net.Listen("tcp", ":"+dataPort) + if err != nil { + slog.Error("listen error", err) + return + } + defer lData.Close() + + for { + conn, err := lData.Accept() + if err != nil { + slog.Error("accept error", err) + return + } + processDataConn(conn) + } +} + +func processDataConn(client net.Conn) { + + slog.Info("已建立客户端数据通道 " + client.RemoteAddr().String()) + + // 读取 tag + tagLen, err := utils.ReadByte(client) + if err != nil { + slog.Error("read error", err) + return + } + tagBuf, err := utils.ReadBuffer(client, int(tagLen)) + if err != nil { + slog.Error("read error", err) + return + } + tag := string(tagBuf) + + // 找到用户连接 + data, ok := connMap[tag] + if !ok { + slog.Error("no such connection") + return + } + + // 响应用户 + user := data.Conn + socks5.SendSuccess(user, client) + + // 写入目标地址 + _, err = client.Write([]byte{byte(len(data.Dest))}) + if err != nil { + slog.Error("写入目标地址失败", err) + return + } + _, err = client.Write([]byte(data.Dest)) + if err != nil { + slog.Error("写入目标地址失败", err) + return + } + + // 数据转发 + slog.Info("开始数据转发 " + client.RemoteAddr().String() + " <-> " + data.Dest) + errCh := make(chan error) + go func() { + _, err := io.Copy(client, user) + if err != nil { + slog.Error("processDataConn error c2u", err) + } + errCh <- err + }() + go func() { + _, err := io.Copy(user, client) + if err != nil { + slog.Error("processDataConn error u2c", err) + } + errCh <- err + }() + <-errCh + slog.Info("数据转发结束 " + client.RemoteAddr().String() + " <-> " + data.Dest) + defer func() { + err := user.Close() + if err != nil { + slog.Error("close error", err) + } + err = client.Close() + if err != nil { + slog.Error("close error", err) + } + }() +} + +func startControlTun() { + ctrlPort := os.Getenv("APP_CTRL_PORT") + if ctrlPort == "" { + panic("环境变量 APP_CTRL_PORT 未设置") + } + + slog.Info("监听控制端口 " + ctrlPort) + lControl, err := net.Listen("tcp", ":"+ctrlPort) + if err != nil { + slog.Error("listen error", err) + return + } + defer lControl.Close() + + for { + conn, err := lControl.Accept() + if err != nil { + slog.Error("accept error", err) + return + } + go processController(conn) + } +} + +func processController(controller net.Conn) { + defer controller.Close() + + slog.Info("收到客户端控制连接 " + controller.RemoteAddr().String()) + + reader := bufio.NewReader(controller) + + // 读取端口 + portBuf := make([]byte, 2) + _, err := io.ReadFull(reader, portBuf) + if err != nil { + slog.Error("读取转发端口失败", "err", err) + return + } + port := binary.BigEndian.Uint16(portBuf) + + // 新建代理服务 + slog.Info("新建代理服务", "port", port) + proxy, err := socks5.New(&socks5.Config{ + Name: strconv.Itoa(int(port)), + Port: port, + AuthMethods: []socks5.Authenticator{ + &UserPassAuthenticator{}, + &NoAuthAuthenticator{}, + }, + }) + if err != nil { + slog.Error("代理服务创建失败", err) + return + } + + go func() { + err := proxy.Run() + if err != nil { + slog.Error("代理服务建立失败", err) + return + } + }() + + slog.Info("代理服务已建立", "port", port) + for { + user := <-proxy.Conn + tag := user.Tag() + _, err := controller.Write([]byte{byte(len(tag))}) + if err != nil { + slog.Error("write error", err) + return + } + _, err = controller.Write([]byte(tag)) + slog.Info("已通知客户端建立数据通道") + if err != nil { + slog.Error("write error", err) + return + } + connMap[tag] = user + } +} + +type NoAuthAuthenticator struct { +} + +func (a *NoAuthAuthenticator) Method() socks5.AuthMethod { + return socks5.NoAuth +} + +func (a *NoAuthAuthenticator) Authenticate(ctx context.Context, reader io.Reader, writer io.Writer) (*socks5.AuthContext, error) { + + // 检查用户是否在白名单中,如果不在则返回错误 + conn, ok := writer.(net.Conn) + if !ok { + return nil, errors.New("noAuth 认证失败,无法获取连接信息") + } + + addr := conn.RemoteAddr().String() + client, _, err := net.SplitHostPort(addr) + if err != nil { + return nil, errors.Wrap(err, "noAuth 认证失败") + } + slog.Info("用户的地址为 " + client) + + // 检查此 ip 是否有权限访问目标 node + server, ok := ctx.Value("service").(*socks5.Server) + if !ok { + return nil, errors.New("noAuth 认证失败,无法获取服务信息") + } + + node := server.Name + slog.Debug(" 客户端 " + client + " 请求连接到 " + node) + + var channels []models.Channel + err = orm.DB. + Joins("INNER JOIN public.nodes n ON channels.node_id = n.id AND n.name = ?", node). + Joins("INNER JOIN public.users u ON channels.user_id = u.id"). + Joins("INNER JOIN public.user_ips ip ON u.id = ip.user_id AND ip.ip_address = ?", client). + Find(&channels).Error + if err != nil { + return nil, errors.New("noAuth 认证失败,查询用户权限失败") + } + + if len(channels) == 0 { + return nil, errors.New("noAuth 认证失败,没有权限") + } + if len(channels) > 1 { + slog.Warn(client + " + " + node + "的组合有多个权限结果,这是不应当存在的") + } + + channel := channels[0] + + if !channel.AuthIp { + return nil, errors.New("noAuth 认证失败,没有权限") + } + + if channel.Expiration.Before(time.Now()) { + return nil, errors.New("noAuth 认证失败,权限已过期") + } + + return &socks5.AuthContext{ + Method: socks5.NoAuth, + Timeout: 300, // todo + Payload: nil, + }, nil +} + +type UserPassAuthenticator struct { +} + +func (a *UserPassAuthenticator) Method() socks5.AuthMethod { + return socks5.UserPassAuth +} + +func (a *UserPassAuthenticator) Authenticate(ctx context.Context, reader io.Reader, writer io.Writer) (*socks5.AuthContext, error) { + + // 检查版本 + v, err := utils.ReadByte(reader) + if err != nil { + return nil, errors.Wrap(err, "读取版本号失败") + } + if v != socks5.AuthVersion { + _, err := writer.Write([]byte{socks5.SocksVersion, socks5.AuthFailure}) + if err != nil { + return nil, errors.Wrap(err, "响应认证失败") + } + return nil, errors.New("认证版本参数不正确") + } + + // 读取账号 + uLen, err := utils.ReadByte(reader) + if err != nil { + return nil, errors.Wrap(err, "读取用户名长度失败") + } + usernameBuf, err := utils.ReadBuffer(reader, int(uLen)) + if err != nil { + return nil, errors.Wrap(err, "读取用户名失败") + } + username := string(usernameBuf) + + // 读取密码 + pLen, err := utils.ReadByte(reader) + if err != nil { + return nil, errors.Wrap(err, "读取密码长度失败") + } + passwordBuf, err := utils.ReadBuffer(reader, int(pLen)) + if err != nil { + return nil, errors.Wrap(err, "读取密码失败") + } + password := string(passwordBuf) + + var channels []models.Channel + err = orm.DB. + Where(&models.Channel{ + Username: username, + }). + Find(&channels).Error + if err != nil { + return nil, errors.Wrap(err, "查询用户失败") + } + + if len(channels) == 0 { + return nil, errors.New("没有权限") + } + if len(channels) > 1 { + slog.Warn("用户有多个权限结果,这是不应当存在的") + } + channel := channels[0] + if !channel.AuthPass { + return nil, errors.New("没有权限") + } + + if channel.Expiration.Before(time.Now()) { + return nil, errors.New("权限已过期") + } + + // 检查密码 todo 哈希 + if channel.Password != password { + return nil, errors.New("密码错误") + } + + // 如果用户设置了双验证则检查 ip 是否在白名单中 + if channel.AuthIp { + conn, ok := writer.(net.Conn) + if !ok { + return nil, errors.New("无法获取连接信息") + } + + addr := conn.RemoteAddr().String() + client, _, err := net.SplitHostPort(addr) + if err != nil { + return nil, errors.Wrap(err, "无法获取连接信息") + } + + slog.Info("用户的地址为 " + client) + var ips []models.UserIp + err = orm.DB. + Where(&models.UserIp{ + UserId: channel.UserId, + IpAddress: client, + }). + Find(&ips).Error + if err != nil { + return nil, errors.Wrap(err, "查询用户 ip 失败") + } + + if len(ips) == 0 { + return nil, errors.New("没有权限") + } + } + + _, err = writer.Write([]byte{socks5.AuthVersion, socks5.AuthSuccess}) + if err != nil { + slog.Error("响应认证失败", err) + return nil, err + } + + return &socks5.AuthContext{ + Method: socks5.UserPassAuth, + Timeout: 300, // todo + Payload: nil, + }, nil +} diff --git a/scripts/dev/docker-compose.yaml b/config/dev/docker-compose.yaml similarity index 68% rename from scripts/dev/docker-compose.yaml rename to config/dev/docker-compose.yaml index f19da7a..6ad7ad0 100644 --- a/scripts/dev/docker-compose.yaml +++ b/config/dev/docker-compose.yaml @@ -2,18 +2,6 @@ name: proxy-server services: - frp: - container_name: proxy-server-dev-frp - build: - context: ./frp - dockerfile: Dockerfile - ports: - - "18080:18080" - - "20000-20100:20000-20100" - networks: - - proxy-server-dev - restart: always - postgres: container_name: proxy-server-dev-postgres image: postgres:17 diff --git a/scripts/sql/init.sql b/config/sql/init.sql similarity index 65% rename from scripts/sql/init.sql rename to config/sql/init.sql index 670a2a5..08e3831 100644 --- a/scripts/sql/init.sql +++ b/config/sql/init.sql @@ -1,5 +1,5 @@ -- nodes -DROP TABLE IF EXISTS nodes; +DROP TABLE IF EXISTS nodes CASCADE; CREATE TABLE nodes ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL UNIQUE, @@ -14,7 +14,7 @@ CREATE INDEX devices_provider_index ON nodes (provider); CREATE INDEX devices_location_index ON nodes (location); -- users -DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS users CASCADE; CREATE TABLE users ( id SERIAL PRIMARY KEY, password VARCHAR(255) NOT NULL, @@ -28,7 +28,7 @@ CREATE TABLE users ( ); -- user_ips -DROP TABLE IF EXISTS user_ips; +DROP TABLE IF EXISTS user_ips CASCADE; CREATE TABLE user_ips ( id SERIAL PRIMARY KEY, user_id int NOT NULL REFERENCES users (id) @@ -43,28 +43,29 @@ CREATE INDEX user_ips_user_id_index ON user_ips (user_id); CREATE INDEX user_ips_ip_address_index ON user_ips (ip_address); -- channel -DROP TABLE IF EXISTS channels; +DROP TABLE IF EXISTS channels CASCADE; CREATE TABLE channels ( - id SERIAL PRIMARY KEY, - user_id int NOT NULL REFERENCES users (id) + id SERIAL PRIMARY KEY, + user_id int NOT NULL REFERENCES users (id) ON UPDATE CASCADE ON DELETE CASCADE, - node_id int NOT NULL REFERENCES nodes (id) -- - ON UPDATE CASCADE -- - ON DELETE SET NULL, -- 节点删除后,用户侧需要保留提取记录 - forward_snapshot VARCHAR(255), - protocol VARCHAR(255), - auth_ip bool, - auth_pass bool, - username VARCHAR(255), - password VARCHAR(255), - expiration TIMESTAMP NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - deleted_at TIMESTAMP + node_id int NOT NULL REFERENCES nodes (id) -- + ON UPDATE CASCADE -- + ON DELETE SET NULL, -- 节点删除后,用户侧需要保留提取记录 + node_port int, + protocol VARCHAR(255), + auth_ip bool, + auth_pass bool, + username VARCHAR(255), + password VARCHAR(255), + expiration TIMESTAMP NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + deleted_at TIMESTAMP ); CREATE INDEX channel_user_id_index ON channels (user_id); CREATE INDEX channel_node_id_index ON channels (node_id); +CREATE INDEX channel_username_index ON channels (username); -- ==================== -- 填充数据 diff --git a/scripts/sql/临时迁移记录.txt b/config/sql/临时迁移记录.txt similarity index 100% rename from scripts/sql/临时迁移记录.txt rename to config/sql/临时迁移记录.txt diff --git a/scripts/test/docker-compose.yaml b/config/test/docker-compose.yaml similarity index 76% rename from scripts/test/docker-compose.yaml rename to config/test/docker-compose.yaml index 6979adc..88457fb 100644 --- a/scripts/test/docker-compose.yaml +++ b/config/test/docker-compose.yaml @@ -2,18 +2,6 @@ name: proxy-server services: - frp: - container_name: proxy-server-dev-frp - build: - context: ./frp - dockerfile: Dockerfile - ports: - - "18080:18080" - - "20000-20100:20000-20100" - networks: - - proxy-server-test - restart: always - postgres: container_name: proxy-server-dev-postgres image: postgres:17 @@ -35,7 +23,8 @@ services: context: ../.. dockerfile: Dockerfile environment: - PORT: $PORT + APP_CTRL_PORT: $APP_CTRL_PORT + APP_DATA_PORT: $APP_DATA_PORT DB_HOST: $DB_HOST DB_PORT: $DB_PORT DB_DATABASE: $DB_DATABASE @@ -43,7 +32,8 @@ services: DB_PASSWORD: $DB_PASSWORD DB_TIMEZONE: $DB_TIMEZONE ports: - - "${PORT}:${PORT}" + - "${APP_CTRL_PORT}:${APP_CTRL_PORT}" + - "${APP_DATA_PORT}:${APP_DATA_PORT}" networks: - proxy-server-test depends_on: diff --git a/pkg/socks5/auth.go b/pkg/socks5/auth.go new file mode 100644 index 0000000..b7d5ded --- /dev/null +++ b/pkg/socks5/auth.go @@ -0,0 +1,81 @@ +package socks5 + +import ( + "context" + "github.com/pkg/errors" + "io" + "log/slog" + "proxy-server/pkg/utils" + "slices" +) + +type AuthMethod byte + +const ( + AuthVersion = byte(1) + AuthSuccess = byte(0) + AuthFailure = byte(1) + NoAuth = AuthMethod(0) + UserPassAuth = AuthMethod(2) + NoAcceptable = byte(0xFF) +) + +type Authenticator interface { + Method() AuthMethod + Authenticate(ctx context.Context, reader io.Reader, writer io.Writer) (*AuthContext, error) +} + +// authenticate 执行认证流程 +func (server *Server) authenticate(reader io.Reader, writer io.Writer) (*AuthContext, error) { + + // 版本检查 + err := checkVersion(reader) + if err != nil { + return nil, err + } + + // 获取客户端认证方式 + nAuth, err := utils.ReadByte(reader) + if err != nil { + return nil, err + } + methods, err := utils.ReadBuffer(reader, int(nAuth)) + if err != nil { + return nil, err + } + + // 认证客户端连接 + for _, authenticator := range server.config.AuthMethods { + method := authenticator.Method() + if slices.Contains(methods, byte(method)) { + slog.Debug("使用的认证方式", method) + + _, err := writer.Write([]byte{SocksVersion, byte(method)}) + if err != nil { + slog.Error("响应认证方式失败", err) + return nil, err + } + + ctx := context.WithValue(context.Background(), "service", server) + authContext, err := authenticator.Authenticate(ctx, reader, writer) + if err != nil { + return nil, err + } + return authContext, nil + } + } + + // 无适用的认证方式 + _, err = writer.Write([]byte{SocksVersion, NoAcceptable}) + if err != nil { + return nil, err + } + + return nil, errors.New("没有适用的认证方式") +} + +type AuthContext struct { + Method AuthMethod + Timeout uint + Payload map[string]any +} diff --git a/pkg/socks5/error.go b/pkg/socks5/error.go new file mode 100644 index 0000000..54baaaa --- /dev/null +++ b/pkg/socks5/error.go @@ -0,0 +1,7 @@ +package socks5 + +type ConfigError string + +func (c ConfigError) Error() string { + return string(c) +} diff --git a/pkg/socks5/request.go b/pkg/socks5/request.go new file mode 100644 index 0000000..ec27294 --- /dev/null +++ b/pkg/socks5/request.go @@ -0,0 +1,404 @@ +package socks5 + +import ( + "context" + "fmt" + "github.com/pkg/errors" + "io" + "log/slog" + "net" + "proxy-server/pkg/utils" + "strconv" +) + +const ( + ConnectCommand = byte(1) + BindCommand = byte(2) + AssociateCommand = byte(3) + ipv4Address = byte(1) + fqdnAddress = byte(3) + ipv6Address = byte(4) +) + +const ( + successReply byte = iota + serverFailure + ruleFailure + networkUnreachable + hostUnreachable + connectionRefused + ttlExpired + commandNotSupported + addrTypeNotSupported +) + +var ( + unrecognizedAddrType = fmt.Errorf("Unrecognized address type") +) + +// AddressRewriter is used to rewrite a destination transparently +type AddressRewriter interface { + Rewrite(ctx context.Context, request *Request) (context.Context, *AddrSpec) +} + +// AddrSpec 地址 +type AddrSpec struct { + FQDN string + IP net.IP + Port int +} + +func (a AddrSpec) String() string { + if a.FQDN != "" { + return fmt.Sprintf("%s (%s):%d", a.FQDN, a.IP, a.Port) + } + return fmt.Sprintf("%s:%d", a.IP, a.Port) +} + +// Address returns a string suitable to dial; prefer returning IP-based +// address, fallback to FQDN +func (a AddrSpec) Address() string { + if 0 != len(a.IP) { + return net.JoinHostPort(a.IP.String(), strconv.Itoa(a.Port)) + } + return net.JoinHostPort(a.FQDN, strconv.Itoa(a.Port)) +} + +func (server *Server) request(reader io.Reader, writer io.Writer) (*Request, error) { + + // 检查版本 + err := checkVersion(reader) + if err != nil { + return nil, err + } + + // 检查连接命令 + command, err := utils.ReadByte(reader) + if err != nil { + return nil, err + } + + slog.Debug("客户端使用的连接指令:%v", command) + if command != ConnectCommand && command != BindCommand && command != AssociateCommand { + err = sendReply(writer, commandNotSupported, nil) + if err != nil { + return nil, err + } + return nil, errors.New("不支持该连接指令") + } + + // 跳过保留字段 rsv + _, err = utils.ReadByte(reader) + if err != nil { + return nil, err + } + + // 获取目标地址 + dest, err := server.parseTarget(reader, writer) + if err != nil { + return nil, err + } + + request := &Request{ + Version: SocksVersion, + Command: command, + DestAddr: dest, + bufConn: reader, + } + + return request, nil +} + +func (server *Server) parseTarget(reader io.Reader, writer io.Writer) (*AddrSpec, error) { + dest := &AddrSpec{} + + aTypeBuf := make([]byte, 1) + _, err := reader.Read(aTypeBuf) + if err != nil { + return nil, err + } + + switch aTypeBuf[0] { + + case ipv4Address: + addr := make([]byte, 4) + _, err := io.ReadFull(reader, addr) + if err != nil { + return nil, err + } + dest.IP = addr + + case ipv6Address: + addr := make([]byte, 16) + _, err := io.ReadFull(reader, addr) + if err != nil { + return nil, err + } + dest.IP = addr + + case fqdnAddress: + aLenBuf := make([]byte, 1) + _, err := reader.Read(aLenBuf) + if err != nil { + return nil, err + } + + fqdnBuff := make([]byte, int(aLenBuf[0])) + _, err = io.ReadFull(reader, fqdnBuff) + if err != nil { + return nil, err + } + dest.FQDN = string(fqdnBuff) + + // 域名解析 + addr, err := server.config.Resolver.Resolve(dest.FQDN) + if err != nil { + err := sendReply(writer, hostUnreachable, nil) + if err != nil { + return nil, fmt.Errorf("Failed to send reply: %v", err) + } + return nil, fmt.Errorf("Failed to resolve destination '%v': %v", dest.FQDN, err) + } + dest.IP = addr + + default: + err := sendReply(writer, addrTypeNotSupported, nil) + if err != nil { + return nil, err + } + return nil, unrecognizedAddrType + } + + portBuf := make([]byte, 2) + _, err = io.ReadFull(reader, portBuf) + if err != nil { + return nil, err + } + dest.Port = (int(portBuf[0]) << 8) | int(portBuf[1]) + + return dest, nil +} + +// A Request represents request received by a server +type Request struct { + // Protocol version + Version uint8 + // Requested command + Command uint8 + // AuthContext provided during negotiation + AuthContext *AuthContext + // AddrSpec of the network that sent the request + RemoteAddr *AddrSpec + // AddrSpec of the desired destination + DestAddr *AddrSpec + // AddrSpec of the actual destination (might be affected by rewrite) + realDestAddr *AddrSpec + bufConn io.Reader +} + +func (server *Server) handle(req *Request, conn net.Conn) error { + ctx := context.Background() + + // 目标地址重写 + req.realDestAddr = req.DestAddr + if server.config.Rewriter != nil { + ctx, req.realDestAddr = server.config.Rewriter.Rewrite(ctx, req) + } + + // 根据协商方法建立连接 + switch req.Command { + case ConnectCommand: + return server.handleConnect(ctx, conn, req) + case BindCommand: + return server.handleBind(ctx, conn, req) + case AssociateCommand: + return server.handleAssociate(ctx, conn, req) + default: + return fmt.Errorf("unsupported command: %v", req.Command) + } +} + +func (server *Server) handleConnect(ctx context.Context, conn net.Conn, req *Request) error { + + // 检查规则集约束 + server.config.Logger.Printf("检查约束规则\n") + if ctx_, ok := server.config.Rules.Allow(ctx, req); !ok { + if err := sendReply(conn, ruleFailure, nil); err != nil { + return fmt.Errorf("failed to send reply: %v", err) + } + return fmt.Errorf("request to %v blocked by rules", req.DestAddr) + } else { + ctx = ctx_ + } + + slog.Info("需要向 " + req.DestAddr.Address() + " 建立连接") + server.Conn <- ProxyData{conn, req.realDestAddr.Address()} + return nil + + // 与目标服务器建立连接 + //server.config.Logger.Printf("与目标服务器建立连接\n") + //dial := server.config.Dial + //target, err := dial("tcp", req.realDestAddr.Address()) + //if err != nil { + // msg := err.Error() + // resp := hostUnreachable + // if strings.Contains(msg, "refused") { + // resp = connectionRefused + // } else if strings.Contains(msg, "network is unreachable") { + // resp = networkUnreachable + // } + // + // err := sendReply(Conn, resp, nil) + // if err != nil { + // return fmt.Errorf("failed to send reply: %v", err) + // } + // return fmt.Errorf("request to %v failed: %v", req.DestAddr, err) + //} + //defer closeConnection(target) + // + //// 正常响应 + //slog.Info("连接成功,开始代理流量") + // + //local := target.LocalAddr().(*net.TCPAddr) + //bind := AddrSpec{IP: local.IP, Port: local.Port} + //err = sendReply(Conn, successReply, &bind) + //if err != nil { + // return fmt.Errorf("Failed to send reply: %v", err) + //} + // + //// 配置超时时间和行为 + //timeout := req.AuthContext.Timeout + //slog.Debug("超时时间", "timeout", timeout) + // + //timeoutCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Second) + //defer cancel() + // + //// 代理流量 + //errChan := make(chan error, 2) + //go func() { + // _, err = io.Copy(target, req.bufConn) + // errChan <- err + //}() + //go func() { + // _, err = io.Copy(Conn, target) + // errChan <- err + //}() + // + //for { + // select { + // + // case <-timeoutCtx.Done(): + // slog.Debug("超时断开连接") + // // todo 根据 termination 执行不同的断开行为 + // return nil + // + // case err := <-errChan: + // slog.Debug("主动断开连接") + // if err != nil { + // return errors.Wrap(err, "代理流量出现错误") + // } + // return nil + // } + //} + +} + +func (server *Server) handleBind(ctx context.Context, conn net.Conn, req *Request) error { + // Check if this is allowed + if ctx_, ok := server.config.Rules.Allow(ctx, req); !ok { + if err := sendReply(conn, ruleFailure, nil); err != nil { + return fmt.Errorf("Failed to send reply: %v", err) + } + return fmt.Errorf("Bind to %v blocked by rules", req.DestAddr) + } else { + ctx = ctx_ + } + + // TODO: Support bind + if err := sendReply(conn, commandNotSupported, nil); err != nil { + return fmt.Errorf("Failed to send reply: %v", err) + } + return nil +} + +func (server *Server) handleAssociate(ctx context.Context, conn net.Conn, req *Request) error { + // Check if this is allowed + if ctx_, ok := server.config.Rules.Allow(ctx, req); !ok { + if err := sendReply(conn, ruleFailure, nil); err != nil { + return fmt.Errorf("Failed to send reply: %v", err) + } + return fmt.Errorf("Associate to %v blocked by rules", req.DestAddr) + } else { + ctx = ctx_ + } + + // TODO: Support associate + if err := sendReply(conn, commandNotSupported, nil); err != nil { + return fmt.Errorf("Failed to send reply: %v", err) + } + return nil +} + +func sendReply(w io.Writer, resp uint8, addr *AddrSpec) error { + var addrType uint8 + var addrBody []byte + var addrPort uint16 + switch { + case addr == nil: + addrType = ipv4Address + addrBody = []byte{0, 0, 0, 0} + addrPort = 0 + + case addr.FQDN != "": + addrType = fqdnAddress + addrBody = append([]byte{byte(len(addr.FQDN))}, addr.FQDN...) + addrPort = uint16(addr.Port) + + case addr.IP.To4() != nil: + addrType = ipv4Address + addrBody = addr.IP.To4() + addrPort = uint16(addr.Port) + + case addr.IP.To16() != nil: + addrType = ipv6Address + addrBody = addr.IP.To16() + addrPort = uint16(addr.Port) + + default: + return fmt.Errorf("failed to format address: %v", addr) + } + + msg := make([]byte, 6+len(addrBody)) + msg[0] = SocksVersion + msg[1] = resp + msg[2] = 0 // Reserved + msg[3] = addrType + copy(msg[4:], addrBody) + msg[4+len(addrBody)] = byte(addrPort >> 8) + msg[4+len(addrBody)+1] = byte(addrPort & 0xff) + + _, err := w.Write(msg) + return err +} + +func SendSuccess(user net.Conn, target net.Conn) { + local := target.LocalAddr().(*net.TCPAddr) + bind := AddrSpec{IP: local.IP, Port: local.Port} + err := sendReply(user, successReply, &bind) + if err != nil { + slog.Error("Failed to send reply", err) + } +} + +type ProxyData struct { + // 用户连入的连接 + Conn net.Conn + // 用户目标地址 + Dest string +} + +func (d ProxyData) Tag() string { + local := d.Conn.LocalAddr() + remote := d.Conn.RemoteAddr() + return fmt.Sprintf("%s-%s", remote, local) +} diff --git a/pkg/socks5/resolver.go b/pkg/socks5/resolver.go new file mode 100644 index 0000000..230d6c5 --- /dev/null +++ b/pkg/socks5/resolver.go @@ -0,0 +1,21 @@ +package socks5 + +import ( + "net" +) + +// NameResolver 域名解析器 +type NameResolver interface { + Resolve(name string) (net.IP, error) +} + +// DNSResolver 使用系统 dns 服务解析域名 +type DNSResolver struct{} + +func (d DNSResolver) Resolve(name string) (net.IP, error) { + addr, err := net.ResolveIPAddr("ip", name) + if err != nil { + return nil, err + } + return addr.IP, err +} diff --git a/pkg/socks5/ruleset.go b/pkg/socks5/ruleset.go new file mode 100644 index 0000000..d65699d --- /dev/null +++ b/pkg/socks5/ruleset.go @@ -0,0 +1,41 @@ +package socks5 + +import ( + "context" +) + +// RuleSet is used to provide custom rules to allow or prohibit actions +type RuleSet interface { + Allow(ctx context.Context, req *Request) (context.Context, bool) +} + +// PermitAll returns a RuleSet which allows all types of connections +func PermitAll() RuleSet { + return &PermitCommand{true, true, true} +} + +// PermitNone returns a RuleSet which disallows all types of connections +func PermitNone() RuleSet { + return &PermitCommand{false, false, false} +} + +// PermitCommand is an implementation of the RuleSet which +// enables filtering supported commands +type PermitCommand struct { + EnableConnect bool + EnableBind bool + EnableAssociate bool +} + +func (p *PermitCommand) Allow(ctx context.Context, req *Request) (context.Context, bool) { + switch req.Command { + case ConnectCommand: + return ctx, p.EnableConnect + case BindCommand: + return ctx, p.EnableBind + case AssociateCommand: + return ctx, p.EnableAssociate + } + + return ctx, false +} diff --git a/pkg/socks5/server.go b/pkg/socks5/server.go new file mode 100644 index 0000000..706108a --- /dev/null +++ b/pkg/socks5/server.go @@ -0,0 +1,199 @@ +package socks5 + +import ( + "bufio" + "errors" + "fmt" + "io" + "log" + "log/slog" + "net" + "os" + "proxy-server/pkg/utils" + "strconv" +) + +const ( + SocksVersion = byte(5) +) + +type Config struct { + Name string + + Host string + Port uint16 + + // 认证方法 + AuthMethods []Authenticator + + // 域名解析 + Resolver NameResolver + + // 自定义认证规则 + Rules RuleSet + + // 地址重写 + Rewriter AddressRewriter + + // 用于 bind 和 associate + BindIP net.IP + + // Logger + Logger *log.Logger + + // 自定义连接流程 + Dial func(network, addr string) (net.Conn, error) +} + +type Server struct { + config *Config + Name string + Port uint16 + Conn chan ProxyData +} + +// New 创建服务器 +func New(conf *Config) (*Server, error) { + if len(conf.AuthMethods) == 0 { + return nil, ConfigError("认证方法不能为空") + } + + if conf.Resolver == nil { + conf.Resolver = DNSResolver{} + } + + if conf.Rules == nil { + conf.Rules = PermitAll() + } + + if conf.Logger == nil { + conf.Logger = log.New(os.Stdout, "", log.LstdFlags) + } + + if conf.Dial == nil { + conf.Dial = func(network, addr string) (net.Conn, error) { + return net.Dial(network, addr) + } + } + + return &Server{ + config: conf, + Name: conf.Name, + Port: conf.Port, + Conn: make(chan ProxyData, 100), + }, nil +} + +// Run 监听端口 +func (server *Server) Run() error { + host := server.config.Host + port := server.config.Port + addr := net.JoinHostPort(host, strconv.Itoa(int(port))) + + slog.Info("启动 socks5 代理服务") + + listener, err := net.Listen("tcp", addr) + if err != nil { + return err + } + defer closeListener(listener) + + slog.Info("代理服务已启动,正在监听端口 " + addr) + + for { + conn, err := listener.Accept() + if err != nil { + slog.Error("客户端连接失败", err) + continue + } + + go func() { + err := server.serve(conn) + if err != nil { + slog.Error("连接异常退出", err) + } + }() + } +} + +// serve 建立连接 +func (server *Server) serve(conn net.Conn) error { + slog.Info("收到来自" + conn.RemoteAddr().String() + "的连接") + + reader := bufio.NewReader(conn) + + // 认证 + slog.Debug("开始认证流程") + authContext, err := server.authenticate(reader, conn) + if err != nil { + conn.Close() + slog.Error("认证失败", err) + return err + } else { + slog.Debug("认证完成") + } + + // 处理连接请求 + slog.Debug("处理连接请求") + request, err := server.request(reader, conn) + if err != nil { + slog.Error("连接请求处理失败", err) + return err + } else { + slog.Debug("连接请求处理完成") + } + + request.AuthContext = authContext + client, ok := conn.RemoteAddr().(*net.TCPAddr) + if !ok { + return fmt.Errorf("获取客户端地址失败") + } + + request.RemoteAddr = &AddrSpec{ + IP: client.IP, + Port: client.Port, + } + + // 处理请求 + slog.Debug("开始代理流量") + err = server.handle(request, conn) + if err != nil { + return err + } + + return nil +} + +// checkVersion 检查客户端版本 +func checkVersion(reader io.Reader) error { + version, err := utils.ReadByte(reader) + if err != nil { + return err + } + + slog.Debug("客户端请求版本", "version", version) + + if version != SocksVersion { + return errors.New("客户端版本不兼容") + } + + return nil +} + +// closeListener 关闭监听并处理可能的错误 +func closeListener(listener net.Listener) { + err := listener.Close() + if err != nil { + slog.Info("结束监听端口") + } +} + +// closeConnection 关闭连接并处理可能的错误 +func closeConnection(conn net.Conn) { + err := conn.Close() + if err != nil { + slog.Error("连接异常关闭", err) + } else { + slog.Info("已关闭来自" + conn.RemoteAddr().String() + "的连接") + } +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go new file mode 100644 index 0000000..900f7fc --- /dev/null +++ b/pkg/utils/utils.go @@ -0,0 +1,22 @@ +package utils + +import "io" + +func ReadByte(reader io.Reader) (byte, error) { + buffer, err := ReadBuffer(reader, 1) + if err != nil { + return 0, err + } + + return buffer[0], nil +} + +func ReadBuffer(reader io.Reader, size int) ([]byte, error) { + buffer := make([]byte, size) + _, err := io.ReadFull(reader, buffer) + if err != nil { + return nil, err + } + + return buffer, nil +} diff --git a/scripts/build.ps1 b/scripts/build.ps1 deleted file mode 100644 index 4131777..0000000 --- a/scripts/build.ps1 +++ /dev/null @@ -1,42 +0,0 @@ -Remove-Job * - -$tasks = 0 -$start = Get-Date - -$tasks++ -Write-Output "building proxy for windows amd64..." -Start-Job -ScriptBlock { - $env:GOOS = "windows"; $env:GOARCH = "amd64"; go build -ldflags '-w -s' -o bin/proxy_win_amd64.exe main.go -} | Out-Null - -$tasks++ -Write-Output "building proxy for linux amd64..." -Start-Job -ScriptBlock { - $env:GOOS = "linux"; $env:GOARCH = "amd64"; go build -ldflags '-w -s' -o bin/proxy_linux_amd64 main.go -} | Out-Null - -$tasks++ -Write-Output "building proxy for linux arm64..." -Start-Job -ScriptBlock { - $env:GOOS = "linux"; $env:GOARCH = "arm64"; go build -ldflags '-w -s' -o bin/proxy_linux_arm64 main.go -} | Out-Null - -# Wait for all jobs to complete -while ($tasks -gt 0) -{ - foreach ($job in Get-Job) - { - if ($job.State -eq "Completed") - { - $tasks-- - $job | Receive-Job - $job | Remove-Job - } - } -} - -$end = Get-Date - -Write-Output "build completed" -Write-Output "time taken: $( ($end - $start).TotalSeconds ) seconds" -Write-Output "output files are in ./bin/" \ No newline at end of file diff --git a/scripts/dev/frp/Dockerfile b/scripts/dev/frp/Dockerfile deleted file mode 100644 index 2552e8d..0000000 --- a/scripts/dev/frp/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM ubuntu:20.04 - -WORKDIR /app - -COPY frps frps -COPY frps.toml frps.toml - -EXPOSE 18080 - -CMD ["./frps", "-c", "frps.toml"] \ No newline at end of file diff --git a/scripts/dev/frp/frps b/scripts/dev/frp/frps deleted file mode 100644 index 2a4a864..0000000 Binary files a/scripts/dev/frp/frps and /dev/null differ diff --git a/scripts/dev/frp/frps.toml b/scripts/dev/frp/frps.toml deleted file mode 100644 index d8dff09..0000000 --- a/scripts/dev/frp/frps.toml +++ /dev/null @@ -1,14 +0,0 @@ -bindPort = 18080 -transport.tcpMux = true - -[[httpPlugins]] -name = "chan-req" -addr = "host.docker.internal:8080" -path = "/chan/request" -ops = ["NewUserConn"] - -[[httpPlugins]] -name = "chan-test" -addr = "host.docker.internal:8080" -path = "/chan/test" -ops = ["NewProxy", "NewWorkConn"] \ No newline at end of file diff --git a/scripts/test/frp/Dockerfile b/scripts/test/frp/Dockerfile deleted file mode 100644 index 2552e8d..0000000 --- a/scripts/test/frp/Dockerfile +++ /dev/null @@ -1,10 +0,0 @@ -FROM ubuntu:20.04 - -WORKDIR /app - -COPY frps frps -COPY frps.toml frps.toml - -EXPOSE 18080 - -CMD ["./frps", "-c", "frps.toml"] \ No newline at end of file diff --git a/scripts/test/frp/frps b/scripts/test/frp/frps deleted file mode 100644 index 2a4a864..0000000 Binary files a/scripts/test/frp/frps and /dev/null differ diff --git a/scripts/test/frp/frps.toml b/scripts/test/frp/frps.toml deleted file mode 100644 index e9f3d4c..0000000 --- a/scripts/test/frp/frps.toml +++ /dev/null @@ -1,8 +0,0 @@ -bindPort = 18080 -transport.tcpMux = true - -[[httpPlugins]] -name = "chan-req" -addr = "service:8080" -path = "/chan/request" -ops = ["NewUserConn"] diff --git a/server/web/app/models/user-ip.go b/server/web/app/models/user-ip.go new file mode 100644 index 0000000..403cd69 --- /dev/null +++ b/server/web/app/models/user-ip.go @@ -0,0 +1,9 @@ +package models + +import "gorm.io/gorm" + +type UserIp struct { + gorm.Model + UserId uint + IpAddress string +}