├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── golangci-lint.yml │ ├── releaser.yml │ └── tests.yml ├── .gitignore ├── .golangci.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── README_zh.md ├── build.bat ├── cmd └── frpmgr │ ├── main.go │ ├── manifest.xml │ ├── resource.h │ ├── resource.rc │ └── singleton.go ├── docs ├── donate-wechat.jpg ├── screenshot_en.png └── screenshot_zh.png ├── generate.go ├── go.mod ├── go.sum ├── i18n ├── catalog.go ├── locales │ ├── en-US │ │ └── messages.gotext.json │ ├── es-ES │ │ └── messages.gotext.json │ ├── ja-JP │ │ └── messages.gotext.json │ ├── ko-KR │ │ └── messages.gotext.json │ ├── zh-CN │ │ └── messages.gotext.json │ └── zh-TW │ │ └── messages.gotext.json └── text.go ├── icon ├── app.ico ├── app.svg ├── dot.ico └── dot.svg ├── installer ├── .gitignore ├── actions │ ├── .gitignore │ ├── actions.sln │ └── actions │ │ ├── CustomAction.config │ │ ├── CustomAction.cs │ │ ├── Properties │ │ └── AssemblyInfo.cs │ │ └── actions.csproj ├── build.bat ├── msi │ ├── en-US.wxl │ ├── es-ES.wxl │ ├── frpmgr.wxs │ ├── ja-JP.wxl │ ├── ko-KR.wxl │ ├── zh-CN.wxl │ └── zh-TW.wxl └── setup │ ├── manifest.xml │ ├── resource.h │ ├── resource.rc │ └── setup.c ├── pkg ├── config │ ├── app.go │ ├── app_test.go │ ├── client.go │ ├── client_test.go │ ├── conf.go │ ├── conf_test.go │ ├── conversion.go │ └── v1.go ├── consts │ ├── config.go │ └── state.go ├── ipc │ ├── client.go │ ├── pipe.go │ └── server.go ├── layout │ ├── greedy.go │ └── greedy_test.go ├── res │ └── res.go ├── sec │ ├── passwd.go │ └── passwd_test.go ├── util │ ├── file.go │ ├── file_test.go │ ├── misc.go │ ├── misc_test.go │ ├── net.go │ ├── strings.go │ └── strings_test.go ├── validators │ ├── passwd.go │ ├── presenter.go │ ├── regexp.go │ └── regexp_test.go └── version │ └── version.go ├── resource.go ├── services ├── client.go ├── frp.go ├── install.go ├── service.go └── tracker.go └── ui ├── aboutpage.go ├── composite.go ├── conf.go ├── confpage.go ├── confview.go ├── detailview.go ├── editclient.go ├── editproxy.go ├── icon.go ├── logpage.go ├── model.go ├── nathole.go ├── panelview.go ├── pluginproxy.go ├── portproxy.go ├── prefpage.go ├── proxytracker.go ├── proxyview.go ├── quickadd.go ├── simpleproxy.go ├── ui.go ├── urlimport.go └── validate.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: https://github.com/koho/frpmgr/blob/master/docs/donate-wechat.jpg 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Version** 14 | Include the version in the About page. 15 | 16 | **To Reproduce** 17 | Steps to reproduce the behavior: 18 | 1. Go to '...' 19 | 2. Click on '....' 20 | 3. Scroll down to '....' 21 | 4. See error 22 | 23 | **Expected behavior** 24 | A clear and concise description of what you expected to happen. 25 | 26 | **Screenshots** 27 | If applicable, add screenshots to help explain your problem. 28 | 29 | **Desktop (please complete the following information):** 30 | - OS: [e.g. Windows 10] 31 | - Version [e.g. 24H2] 32 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the feature request** 11 | A clear and concise description of what you want to add. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | 10 | jobs: 11 | golangci: 12 | name: Lint 13 | runs-on: windows-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup Go environment 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: '1.23' 22 | 23 | - name: Run 24 | uses: golangci/golangci-lint-action@v4 25 | with: 26 | version: v1.61 27 | -------------------------------------------------------------------------------- /.github/workflows/releaser.yml: -------------------------------------------------------------------------------- 1 | name: Releaser 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | name: Build 10 | runs-on: windows-latest 11 | outputs: 12 | version: ${{ steps.build.outputs.version }} 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | 17 | - name: Setup Go environment 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: '1.23' 21 | 22 | - name: Setup VS environment 23 | shell: cmd 24 | run: | 25 | for /f "usebackq delims=" %%i in (`vswhere.exe -latest -property installationPath`) do echo %%i\VC\Auxiliary\Build>>%GITHUB_PATH% 26 | 27 | - name: Build 28 | id: build 29 | shell: cmd 30 | run: | 31 | for /f "tokens=3" %%a in ('findstr /r "Number.*=.*[0-9.]*" .\pkg\version\version.go') do set VERSION=%%a 32 | echo version=%VERSION:"=%>>%GITHUB_OUTPUT% 33 | build.bat 34 | 35 | - name: Upload assets 36 | uses: actions/upload-artifact@v4 37 | with: 38 | name: assets 39 | path: | 40 | bin/*.exe 41 | bin/*.zip 42 | 43 | release: 44 | name: Release 45 | needs: build 46 | runs-on: ubuntu-latest 47 | steps: 48 | - name: Collect files 49 | uses: actions/download-artifact@v4 50 | with: 51 | name: assets 52 | 53 | - name: Calculate SHA256 checksum 54 | run: sha256sum *.exe *.zip > frpmgr-${{ needs.build.outputs.version }}-sha256-checksums.txt 55 | 56 | - name: Upload release assets 57 | uses: shogo82148/actions-upload-release-asset@v1 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | with: 61 | upload_url: ${{ github.event.release.upload_url }} 62 | asset_path: | 63 | ./*.exe 64 | ./*.zip 65 | ./*.txt 66 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | pull_request: 9 | 10 | jobs: 11 | build: 12 | name: Build 13 | runs-on: windows-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup Go environment 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: '1.23' 22 | 23 | - name: Setup VS environment 24 | shell: cmd 25 | run: | 26 | for /f "usebackq delims=" %%i in (`vswhere.exe -latest -property installationPath`) do echo %%i\VC\Auxiliary\Build>>%GITHUB_PATH% 27 | 28 | - name: Add commit hash to version number 29 | shell: powershell 30 | if: github.event_name == 'pull_request' 31 | env: 32 | HEAD_SHA: ${{ github.event.pull_request.head.sha }} 33 | run: | 34 | $versionFile = ".\pkg\version\version.go" 35 | $rev = [UInt16]("0x" + $env:HEAD_SHA.Substring(0, 4)) 36 | $version = (findstr /r "Number.*=.*[0-9.]*" $versionFile | Select-Object -First 1 | ConvertFrom-StringData).Get_Item("Number") 37 | $newVersion = $version.Substring(0, $version.Length - 1) + ".$rev" + '"' 38 | (Get-Content $versionFile).Replace($version, $newVersion) | Set-Content $versionFile 39 | 40 | - name: Build 41 | shell: cmd 42 | run: build.bat 43 | 44 | - name: Upload 45 | uses: actions/upload-artifact@v4 46 | if: github.event_name == 'pull_request' 47 | with: 48 | name: build 49 | path: | 50 | bin/*.exe 51 | bin/*.zip 52 | retention-days: 5 53 | 54 | test: 55 | name: Go 56 | runs-on: windows-latest 57 | steps: 58 | - name: Checkout 59 | uses: actions/checkout@v4 60 | 61 | - name: Setup Go environment 62 | uses: actions/setup-go@v5 63 | with: 64 | go-version: '1.23' 65 | 66 | - name: Test 67 | run: go test -v ./... 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.ini 2 | logs 3 | bin 4 | *.syso 5 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 5m 3 | 4 | linters: 5 | disable-all: true 6 | enable: 7 | - gosimple 8 | - govet 9 | - ineffassign 10 | - staticcheck 11 | - typecheck 12 | - whitespace 13 | - unused 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog[Deprecated] 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [1.11.0] - 2023-02-10 10 | 11 | ### What's Changed 12 | * View old history log files by @koho in https://github.com/koho/frpmgr/pull/30 13 | * Show item name in dialog title 14 | * Show blue config name when config is set to start manually 15 | * Set gray background color for disabled proxy 16 | * Support go1.20 17 | * Support `bandwidth_limit_mode` option 18 | 19 | ### Update 20 | * FRP v0.47.0 21 | 22 | **Full Changelog**: https://github.com/koho/frpmgr/compare/v1.10.1...v1.11.0 23 | 24 | ## [1.10.1] - 2023-01-10 25 | ### 新增 26 | * 自定义 DNS 服务 27 | * 支持降级安装 28 | 29 | ### 更新 30 | * FRP 版本 0.46.1 31 | 32 | ## [1.10.0] - 2022-12-19 33 | ### 新增 34 | * 认证支持 `oidc_scope` 参数 35 | * 支持 quic 协议 36 | 37 | ### 优化 38 | * 展示 `xtcp`、`stcp`、`sudp` 类型的访问者参数(#27) 39 | 40 | ### 更新 41 | * FRP 版本 0.46.0 42 | 43 | ## [1.9.2] - 2022-10-27 44 | ### 更新 45 | * FRP 版本 0.45.0 46 | 47 | ## [1.9.1] - 2022-07-11 48 | ### 更新 49 | * FRP 版本 0.44.0 50 | 51 | ## [1.9.0] - 2022-07-01 52 | ### 新增 53 | * 多语言支持(#19) 54 | * 支持创建/导入分享链接 55 | * 可创建某个配置的副本,避免参数的重复输入 56 | * 从 URL 导入配置 57 | 58 | ### 优化 59 | * 配置文件统一存放到 `profiles` 目录 60 | 61 | ## [1.8.1] - 2022-05-28 62 | ### 新增 63 | * 新的代理参数「路由用户」(`route_by_http_user`) 64 | 65 | ### 更新 66 | * FRP 版本 0.43.0 67 | 68 | ## [1.8.0] - 2022-05-15 69 | ### 新增 70 | * 从剪贴板导入配置/代理 71 | * 支持拖拽文件导入配置 72 | * 在文件夹中显示配置 73 | 74 | ### 优化 75 | * 减少安装包体积(-48%) 76 | * 升级时默认选择上次安装的目录 77 | * 导入文件前验证配置文件 78 | 79 | ## [1.7.2] - 2022-04-22 80 | ### 更新 81 | * FRP 版本 0.42.0 82 | 83 | ## [1.7.1] - 2022-04-13 84 | ### 新增 85 | * "快速添加"支持更多类型,如 FTP、文件服务等 86 | * 快捷启用/禁用代理条目 87 | * 新增 TLS、心跳、复用等配置选项 88 | * 代理条目右键菜单新增"复制访问地址"功能 89 | 90 | ### 修复 91 | * 修复 Win7 下无法打开服务窗口 92 | 93 | ### 优化 94 | * 防止同一用户下 GUI 窗口多开 95 | * 启动配置前验证配置文件 96 | * 保存代理条目前验证代理条目 97 | * 使用范围端口时自动添加前缀 98 | 99 | ## [1.7.0] - 2022-03-24 100 | ### 新增 101 | * 支持全部代理类型(本次新增`sudp`, `http`, `https`, `tcpmux`)的图形化配置 102 | * 新增插件编辑 103 | * 新增负载均衡 104 | * 新增健康检查 105 | * 新增带宽限制,代理协议版本配置 106 | * 代理项目表格新增了子域名,自定义域名,插件列 107 | * 添加连接超时时间,心跳间隔时间配置 108 | * 添加 pprof 开关 109 | 110 | ### 修复 111 | * 修复在中文配置名下,打开服务按钮无反应的问题 112 | * 修复随机名称按钮会生成相同名称问题 113 | * 修复了小概率界面崩溃问题 114 | 115 | ### 优化 116 | * 无法添加相同名称的代理 117 | * 无法导入相同名称的配置,当以压缩包导入时,忽略同名配置导入 118 | * 减少了不必要的 IO 查询 119 | * 代理项目表格各列宽调整,以充分利用空间 120 | * 手动指定日志文件后修改配置名不再自动改变日志路径配置 121 | * 路径配置的输入框添加浏览文件按钮 122 | 123 | ### 更新 124 | * FRP 版本 0.41.0 125 | 126 | ## [1.6.1] - 2022-03-07 127 | ### 优化 128 | * 安装包改用 exe 格式,避免无法关闭占用程序 129 | * 升级完成后自动重启之前运行的服务 130 | 131 | ## [1.6.0] - 2022-02-14 132 | ### 新增 133 | * 配置编辑支持自定义参数(#12) 134 | * 打开配置文件入口 135 | * 项目编辑可生成随机名称 136 | * 复制服务器地址入口 137 | * 添加`connect_server_local_ip`,`http_proxy`,`user`编辑入口 138 | 139 | ### 优化 140 | * 减少不必要的视图更新 141 | * 优化系统缩放时的界面显示 142 | 143 | ### 更新 144 | * FRP 版本 0.39.1 145 | 146 | ## [1.5.0] - 2022-01-05 147 | ### 更新 148 | * FRP 版本 0.38.0 149 | 150 | ## [1.4.2] - 2021-09-08 151 | ### 新增 152 | * 可单独设定配置的服务启动方式(手动/自动)(#9) 153 | 154 | ### 修复 155 | * 修复某些情况下无法查看服务的异常 156 | 157 | ## [1.4.1] - 2021-09-07 158 | ### 新增 159 | * 支持配置xtcp/stcp类型(#8) 160 | * 添加自定义选项支持 161 | * 查看服务属性入口(#9) 162 | 163 | ### 更新 164 | * FRP 版本 0.37.1 165 | 166 | ## [1.4.0] - 2021-07-12 167 | ### 修复 168 | * 修复日志文件的卸载错误提示 169 | 170 | ### 更新 171 | * FRP 版本 0.37.0 172 | 173 | ## [1.3.2] - 2020-12-16 174 | ### 新增 175 | * 支持双击编辑 176 | 177 | ### 优化 178 | * 小幅UI优化 179 | 180 | ## [1.3.1] - 2020-12-16 181 | ### 新增 182 | * 添加文件版本信息 183 | 184 | ### 修复 185 | * 修复卸载程序时的DLL错误 186 | 187 | ## [1.3.0] - 2020-12-13 188 | ### 新增 189 | * 添加关于页面 190 | * 支持导出配置文件 191 | 192 | ### 优化 193 | * 日志实时显示 194 | * 小幅UI优化 195 | 196 | ### 修复 197 | * 修复卸载时日志文件无法删除的问题 198 | 199 | ## [1.2.5] - 2020-12-03 200 | ### 优化 201 | * 小幅 UI 逻辑优化 202 | * 相关日志文件重命名/删除 203 | 204 | ### 修复 205 | * 修复 Windows 7 下的闪退问题(#2) 206 | 207 | ## [1.2.4] - 2020-08-17 208 | ### 新增 209 | * 添加自定义DNS服务器的支持,对于使用动态DNS的服务器可以减少离线时间 210 | 211 | ### 修复 212 | * 修复了一些编译错误 213 | 214 | ## [1.2.3] - 2020-05-24 215 | ### 修复 216 | * 解决某些情况下电脑重启后服务没有自动运行问题 217 | * 更新软件后需打开软件,选择左侧配置项后右键编辑,然后直接确定,再启动即可 218 | 219 | [Unreleased]: https://github.com/koho/frpmgr/compare/v1.11.0...HEAD 220 | [1.11.0]: https://github.com/koho/frpmgr/compare/v1.10.1...v1.11.0 221 | [1.10.1]: https://github.com/koho/frpmgr/compare/v1.10.0...v1.10.1 222 | [1.10.0]: https://github.com/koho/frpmgr/compare/v1.9.2...v1.10.0 223 | [1.9.2]: https://github.com/koho/frpmgr/compare/v1.9.1...v1.9.2 224 | [1.9.1]: https://github.com/koho/frpmgr/compare/v1.9.0...v1.9.1 225 | [1.9.0]: https://github.com/koho/frpmgr/compare/v1.8.1...v1.9.0 226 | [1.8.1]: https://github.com/koho/frpmgr/compare/v1.8.0...v1.8.1 227 | [1.8.0]: https://github.com/koho/frpmgr/compare/v1.7.2...v1.8.0 228 | [1.7.2]: https://github.com/koho/frpmgr/compare/v1.7.1...v1.7.2 229 | [1.7.1]: https://github.com/koho/frpmgr/compare/v1.7.0...v1.7.1 230 | [1.7.0]: https://github.com/koho/frpmgr/compare/v1.6.1...v1.7.0 231 | [1.6.1]: https://github.com/koho/frpmgr/compare/v1.6.0...v1.6.1 232 | [1.6.0]: https://github.com/koho/frpmgr/compare/v1.5.0...v1.6.0 233 | [1.5.0]: https://github.com/koho/frpmgr/compare/v1.4.2...v1.5.0 234 | [1.4.2]: https://github.com/koho/frpmgr/compare/v1.4.1...v1.4.2 235 | [1.4.1]: https://github.com/koho/frpmgr/compare/v1.4.0...v1.4.1 236 | [1.4.0]: https://github.com/koho/frpmgr/compare/v1.3.2...v1.4.0 237 | [1.3.2]: https://github.com/koho/frpmgr/compare/v1.3.1...v1.3.2 238 | [1.3.1]: https://github.com/koho/frpmgr/compare/v1.3.0...v1.3.1 239 | [1.3.0]: https://github.com/koho/frpmgr/compare/v1.2.5...v1.3.0 240 | [1.2.5]: https://github.com/koho/frpmgr/compare/v1.2.4...v1.2.5 241 | [1.2.4]: https://github.com/koho/frpmgr/compare/v1.2.3...v1.2.4 242 | [1.2.3]: https://github.com/koho/frpmgr/releases/tag/v1.2.3 243 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FRP Manager 2 | 3 | [![GitHub Release](https://img.shields.io/github/tag/koho/frpmgr.svg?label=release)](https://github.com/koho/frpmgr/releases) 4 | [![FRP Version](https://img.shields.io/endpoint?url=https%3A%2F%2Fgo.shields.workers.dev%2Fkoho%2Ffrpmgr%2Fmaster%3Fname%3Dfrp)](https://github.com/fatedier/frp) 5 | [![GitHub Downloads](https://img.shields.io/github/downloads/koho/frpmgr/total.svg)](https://github.com/koho/frpmgr/releases) 6 | 7 | English | [简体中文](README_zh.md) 8 | 9 | FRP Manager is a multi-node, graphical reverse proxy tool designed for [FRP](https://github.com/fatedier/frp) on Windows. It allows users to setup reverse proxy easily without writing the configuration file. FRP Manager offers a complete solution including editor, launcher, status tracking, and hot reload. 10 | 11 | The tool was inspired by a common use case where we often need to combine multiple tools including client, configuration file, and launcher to create a stable service that exposes a local server behind a NAT or firewall to the Internet. Now, with FRP Manager, an all-in-one solution, you can avoid many tedious operations when deploying a reverse proxy. 12 | 13 | The latest release requires at least Windows 10 or Server 2016. Please visit the **[Wiki](https://github.com/koho/frpmgr/wiki)** for comprehensive guides. 14 | 15 | ![screenshot](/docs/screenshot_en.png) 16 | 17 | ## Features 18 | 19 | - **Closable GUI:** All launched configurations will run independently as background services, so you can close the GUI after finishing all settings. 20 | - **Auto-start:** A launched configuration is registered as an auto-start service by default and starts automatically during system boot (no login required). 21 | - **Hot reload:** Allows users to apply proxy changes to a running configuration without restarting the service and without losing proxy state. 22 | - **Multiple configurations:** It's easy to connect to multiple nodes by creating multiple configurations. 23 | - **Import and export configurations:** Provides the option to import configuration file from multiple sources, including local file, clipboard, and HTTP. 24 | - **Self-destructing configuration:** A special configuration that disappears and becomes unreachable after a certain amount of time. 25 | - **Status tracking:** You can check the proxy status directly in the table view without looking at the logs. 26 | 27 | Visit the **[Wiki](https://github.com/koho/frpmgr/wiki)** for comprehensive guides, including: 28 | 29 | - **[Installation Instructions](https://github.com/koho/frpmgr/wiki#how-to-install):** Install or upgrade FRP Manager on Windows. 30 | - **[Quick Start Guide](https://github.com/koho/frpmgr/wiki/Quick-Start):** Learn how to connect to your node and setup a proxy in minutes. 31 | - **[Configuration](https://github.com/koho/frpmgr/wiki/Configuration):** Explore configuration, proxy, visitor, and log. 32 | - **[Examples](https://github.com/koho/frpmgr/wiki/Examples):** There are some common examples to help you learn FRP Manager. 33 | 34 | ## Building 35 | 36 | To build FRP Manager from source, you need to install the following dependencies: 37 | 38 | - Go 39 | - Visual Studio 40 | - [MinGW](https://www.mingw-w64.org/) 41 | - [WiX Toolset](https://wixtoolset.org/) v3.14 42 | 43 | Once Visual Studio is installed, add the [developer command file directory](https://learn.microsoft.com/en-us/cpp/build/building-on-the-command-line?view=msvc-170#developer_command_file_locations) (e.g., `C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build`) to the `PATH` environment variable. Likewise, do the same for the `bin` directory of MinGW. 44 | 45 | You can compile the project by opening the terminal: 46 | 47 | ```shell 48 | git clone https://github.com/koho/frpmgr 49 | cd frpmgr 50 | build.bat 51 | ``` 52 | 53 | The generated installation files are located in the `bin` directory. 54 | 55 | ### Debugging 56 | 57 | If you're building the project for the first time, you need to compile resources: 58 | 59 | ```shell 60 | go generate 61 | ``` 62 | 63 | The command does not need to be executed again unless the project's resources change. 64 | 65 | After that, the application can be run directly: 66 | 67 | ```shell 68 | go run ./cmd/frpmgr 69 | ``` 70 | 71 | ## Donation 72 | 73 | If this project is useful to you, consider supporting its development in one of the following ways: 74 | 75 | - [**WeChat**](/docs/donate-wechat.jpg) 76 | -------------------------------------------------------------------------------- /README_zh.md: -------------------------------------------------------------------------------- 1 | # FRP 管理器 2 | 3 | [![GitHub Release](https://img.shields.io/github/tag/koho/frpmgr.svg?label=release)](https://github.com/koho/frpmgr/releases) 4 | [![FRP Version](https://img.shields.io/endpoint?url=https%3A%2F%2Fgo.shields.workers.dev%2Fkoho%2Ffrpmgr%2Fmaster%3Fname%3Dfrp)](https://github.com/fatedier/frp) 5 | [![GitHub Downloads](https://img.shields.io/github/downloads/koho/frpmgr/total.svg)](https://github.com/koho/frpmgr/releases) 6 | 7 | [English](README.md) | 简体中文 8 | 9 | FRP 管理器是一个多节点、图形化反向代理工具,专为 Windows 上的 [FRP](https://github.com/fatedier/frp) 设计。它允许用户轻松设置反向代理,而无需编写配置文件。FRP 管理器提供了一套完整的解决方案,包括编辑器、启动器、状态跟踪和热重载。 10 | 11 | 该工具的灵感来自于一个常见的用例,我们经常需要组合使用多种工具,包括客户端、配置文件和启动器,以创建一个稳定的服务,将位于 NAT 或防火墙后的本地服务器暴露到互联网。现在,有了 FRP 管理器这个一体化解决方案,您可以在部署反向代理时省去许多繁琐的操作。 12 | 13 | 最新版本至少需要 Windows 10 或 Server 2016。请访问 **[Wiki](https://github.com/koho/frpmgr/wiki)** 获取完整指南。 14 | 15 | ![screenshot](/docs/screenshot_zh.png) 16 | 17 | ## 特征 18 | 19 | - **界面可退出:**​所有已启动的配置都将作为后台服务独立运行,因此您可以在完成所有设置后关闭界面。 20 | - **开机自启:**​已启动的配置默认注册为自动启动服务,并在系统启动时自动启动(无需登录)。 21 | - **热重载:**​允许用户将代理更改应用于正在运行的配置,而无需重启服务,也不会丢失代理状态。 22 | - **多配置文件管理:**​通过创建多个配置,可以轻松连接到多个节点。 23 | - **导入和导出配置:**​提供从多个来源导入配置文件的选项,包括本地文件、剪贴板和 HTTP。 24 | - **自毁配置:**​一种特殊配置,会在指定的时间后删除并无法访问。 25 | - **状态跟踪:**​您可以直接在表格视图中查看代理状态,而无需查看日志。 26 | 27 | 访问 **[Wiki](https://github.com/koho/frpmgr/wiki)** 获取完整指南,包括: 28 | 29 | - **[安装说明](https://github.com/koho/frpmgr/wiki#how-to-install):**​在 Windows 上安装或升级 FRP 管理器。 30 | - **[快速入门指南](https://github.com/koho/frpmgr/wiki/Quick-Start):**​了解如何在几分钟内连接到您的节点并设置代理。 31 | - **[配置](https://github.com/koho/frpmgr/wiki/Configuration):**​探索配置、代理、访问者和日志。 32 | - **[示例](https://github.com/koho/frpmgr/wiki/Examples):**​这里有一些常见的示例可以帮助您学习 FRP 管理器。 33 | 34 | ## 构建 35 | 36 | 要从源代码构建 FRP 管理器,您需要安装以下依赖项: 37 | 38 | - Go 39 | - Visual Studio 40 | - [MinGW](https://www.mingw-w64.org/) 41 | - [WiX Toolset](https://wixtoolset.org/) v3.14 42 | 43 | 安装 Visual Studio 后,将 [开发者命令文件目录](https://learn.microsoft.com/en-us/cpp/build/building-on-the-command-line?view=msvc-170#developer_command_file_locations)(例如 `C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Auxiliary\Build`)添加到 `PATH` 环境变量中。同样,将 MinGW 的 `bin` 目录也添加到其中。 44 | 45 | 您可以通过打开终端来编译项目: 46 | 47 | ```shell 48 | git clone https://github.com/koho/frpmgr 49 | cd frpmgr 50 | build.bat 51 | ``` 52 | 53 | 生成的安装文件位于 `bin` 目录。 54 | 55 | ### 调试 56 | 57 | 如果您是首次构建项目,则需要编译资源: 58 | 59 | ```shell 60 | go generate 61 | ``` 62 | 63 | 除非项目资源发生变化,否则无需再次执行该命令。 64 | 65 | 之后,即可直接运行该应用程序: 66 | 67 | ```shell 68 | go run ./cmd/frpmgr 69 | ``` 70 | 71 | ## 捐助 72 | 73 | 如果本项目对您有帮助,请考虑通过以下方式支持其开发: 74 | 75 | - [**微信**](/docs/donate-wechat.jpg) 76 | -------------------------------------------------------------------------------- /build.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal enabledelayedexpansion 3 | set BUILDDIR=%~dp0 4 | set ARCHS=amd64 386 5 | cd /d %BUILDDIR% || exit /b 1 6 | 7 | :packages 8 | echo [+] Downloading packages 9 | go mod tidy || goto :error 10 | 11 | :resources 12 | echo [+] Generating resources 13 | for /f %%a in ('go generate') do set %%a 14 | if not defined VERSION exit /b 1 15 | 16 | :build 17 | echo [+] Building program 18 | set MOD=github.com/koho/frpmgr 19 | set GO111MODULE=on 20 | set CGO_ENABLED=0 21 | for %%a in (%ARCHS%) do ( 22 | set GOARCH=%%a 23 | go build -trimpath -ldflags="-H windowsgui -s -w -X %MOD%/pkg/version.BuildDate=%BUILD_DATE%" -o bin\x!GOARCH:~-2!\frpmgr.exe .\cmd\frpmgr || goto :error 24 | ) 25 | 26 | :archive 27 | echo [+] Creating archives 28 | for %%a in (%ARCHS%) do ( 29 | set ARCH=%%a 30 | tar -ac -C bin\x!ARCH:~-2! -f bin\frpmgr-%VERSION%-x!ARCH:~-2!.zip frpmgr.exe || goto :error 31 | ) 32 | 33 | :installer 34 | echo [+] Building installer 35 | call installer\build.bat %VERSION% || goto :error 36 | 37 | :success 38 | echo [+] Success 39 | exit /b 0 40 | 41 | :error 42 | echo [-] Failed with error %errorlevel%. 43 | exit /b %errorlevel% 44 | -------------------------------------------------------------------------------- /cmd/frpmgr/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "strings" 9 | "syscall" 10 | 11 | "golang.org/x/sys/windows" 12 | "golang.org/x/sys/windows/svc" 13 | 14 | "github.com/koho/frpmgr/i18n" 15 | "github.com/koho/frpmgr/pkg/version" 16 | "github.com/koho/frpmgr/services" 17 | "github.com/koho/frpmgr/ui" 18 | ) 19 | 20 | func fatal(v ...interface{}) { 21 | windows.MessageBox(0, windows.StringToUTF16Ptr(fmt.Sprint(v...)), windows.StringToUTF16Ptr(ui.AppLocalName), windows.MB_ICONERROR) 22 | os.Exit(1) 23 | } 24 | 25 | func info(title string, format string, v ...interface{}) { 26 | windows.MessageBox(0, windows.StringToUTF16Ptr(i18n.Sprintf(format, v...)), windows.StringToUTF16Ptr(title), windows.MB_ICONINFORMATION) 27 | } 28 | 29 | var ( 30 | confPath string 31 | showVersion bool 32 | showHelp bool 33 | flagOutput strings.Builder 34 | ) 35 | 36 | func init() { 37 | flag.StringVar(&confPath, "c", "", "The path to config `file` (Service-only).") 38 | flag.BoolVar(&showVersion, "v", false, "Display version information.") 39 | flag.BoolVar(&showHelp, "h", false, "Show help information.") 40 | flag.CommandLine.SetOutput(&flagOutput) 41 | flag.Parse() 42 | } 43 | 44 | func main() { 45 | if showHelp { 46 | flag.Usage() 47 | info(ui.AppLocalName, flagOutput.String()) 48 | return 49 | } 50 | if showVersion { 51 | info(ui.AppLocalName, strings.Join([]string{ 52 | i18n.Sprintf("Version: %s", version.Number), 53 | i18n.Sprintf("FRP version: %s", version.FRPVersion), 54 | i18n.Sprintf("Built on: %s", version.BuildDate), 55 | }, "\n")) 56 | return 57 | } 58 | inService, err := svc.IsWindowsService() 59 | if err != nil { 60 | fatal(err) 61 | } 62 | if inService { 63 | if confPath == "" { 64 | os.Exit(1) 65 | return 66 | } 67 | if err = services.Run(confPath); err != nil { 68 | fatal(err) 69 | } 70 | } else { 71 | h, err := checkSingleton() 72 | defer windows.CloseHandle(h) 73 | if errors.Is(err, syscall.ERROR_ALREADY_EXISTS) { 74 | showMainWindow() 75 | return 76 | } 77 | if err = ui.RunUI(); err != nil { 78 | fatal(err) 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /cmd/frpmgr/manifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | PerMonitorV2, PerMonitor 19 | True 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /cmd/frpmgr/resource.h: -------------------------------------------------------------------------------- 1 | #ifndef _RESOURCE_H 2 | #define _RESOURCE_H 3 | 4 | #define IDI_APP 7 5 | #define IDI_DOT 21 6 | 7 | #define IDD_VALIDATE VALDLG 8 | 9 | #define IDC_TITLE 2000 10 | #define IDC_STATIC1 2001 11 | #define IDC_STATIC2 2002 12 | #define IDC_EDIT 2003 13 | #define IDC_ICON 2004 14 | 15 | #endif 16 | -------------------------------------------------------------------------------- /cmd/frpmgr/resource.rc: -------------------------------------------------------------------------------- 1 | #include 2 | #include "resource.h" 3 | 4 | #pragma code_page(65001) // UTF-8 5 | 6 | #define STRINGIZE(x) #x 7 | #define EXPAND(x) STRINGIZE(x) 8 | 9 | LANGUAGE LANG_NEUTRAL, SUBLANG_NEUTRAL 10 | CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST manifest.xml 11 | IDI_APP ICON icon/app.ico 12 | IDI_DOT ICON icon/dot.ico 13 | 14 | #define VERSIONINFO_TEMPLATE(block_id, lang_id, charset_id, file_desc) \ 15 | VS_VERSION_INFO VERSIONINFO \ 16 | FILEVERSION VERSION_ARRAY \ 17 | PRODUCTVERSION VERSION_ARRAY \ 18 | FILEFLAGSMASK VS_FFI_FILEFLAGSMASK \ 19 | FILEFLAGS 0x0 \ 20 | FILEOS VOS__WINDOWS32 \ 21 | FILETYPE VFT_APP \ 22 | FILESUBTYPE VFT2_UNKNOWN \ 23 | BEGIN \ 24 | BLOCK "StringFileInfo" \ 25 | BEGIN \ 26 | BLOCK block_id \ 27 | BEGIN \ 28 | VALUE "CompanyName", "FRP Manager Project" \ 29 | VALUE "FileDescription", file_desc \ 30 | VALUE "FileVersion", EXPAND(VERSION_STR) \ 31 | VALUE "InternalName", "frpmgr" \ 32 | VALUE "LegalCopyright", "Copyright © FRP Manager Project" \ 33 | VALUE "OriginalFilename", "frpmgr.exe" \ 34 | VALUE "ProductName", file_desc \ 35 | VALUE "ProductVersion", EXPAND(VERSION_STR) \ 36 | VALUE "Comments", "https://github.com/koho/frpmgr" \ 37 | END \ 38 | END \ 39 | BLOCK "VarFileInfo" \ 40 | BEGIN \ 41 | VALUE "Translation", lang_id, charset_id \ 42 | END \ 43 | END 44 | 45 | LANGUAGE LANG_ENGLISH, SUBLANG_DEFAULT 46 | VERSIONINFO_TEMPLATE( 47 | "040904B0", 0x0409, 1200, 48 | "FRP Manager" 49 | ) 50 | 51 | LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_SIMPLIFIED 52 | VERSIONINFO_TEMPLATE( 53 | "080404B0", 0x0804, 1200, 54 | "FRP 管理器" 55 | ) 56 | 57 | LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_TRADITIONAL 58 | VERSIONINFO_TEMPLATE( 59 | "040404B0", 0x0404, 1200, 60 | "FRP 管理器" 61 | ) 62 | 63 | LANGUAGE LANG_JAPANESE, SUBLANG_DEFAULT 64 | VERSIONINFO_TEMPLATE( 65 | "041104B0", 0x0411, 1200, 66 | "FRP マネージャ" 67 | ) 68 | 69 | LANGUAGE LANG_KOREAN, SUBLANG_DEFAULT 70 | VERSIONINFO_TEMPLATE( 71 | "041204B0", 0x0412, 1200, 72 | "FRP 관리자" 73 | ) 74 | 75 | LANGUAGE LANG_SPANISH, SUBLANG_SPANISH 76 | VERSIONINFO_TEMPLATE( 77 | "0C0A04B0", 0x0C0A, 1200, 78 | "Administrador de FRP" 79 | ) 80 | 81 | LANGUAGE LANG_NEUTRAL, SUBLANG_NEUTRAL 82 | IDD_VALIDATE DIALOGEX 0, 0, 275, 114 83 | STYLE DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU 84 | CAPTION "@IDD_VALIDATE" 85 | FONT 9, "Microsoft YaHei", 400, 0, 0x80 86 | BEGIN 87 | ICON "",IDC_ICON,7,5,20,18 88 | LTEXT "@IDC_TITLE",IDC_TITLE,31,5,237,30 89 | RTEXT "@IDC_STATIC2",IDC_STATIC2,33,59,49,11 90 | EDITTEXT IDC_EDIT,87,58,141,11,ES_PASSWORD | ES_AUTOHSCROLL 91 | GROUPBOX "@IDC_STATIC1",IDC_STATIC1,26,40,222,43 92 | DEFPUSHBUTTON "@IDOK",IDOK,158,95,52,14 93 | PUSHBUTTON "@IDCANCEL",IDCANCEL,216,95,52,14 94 | END 95 | -------------------------------------------------------------------------------- /cmd/frpmgr/singleton.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | "io/fs" 7 | "os" 8 | "syscall" 9 | 10 | "github.com/lxn/win" 11 | "golang.org/x/sys/windows" 12 | ) 13 | 14 | // checkSingleton returns an error when another program is running. 15 | // This function should be only called in gui mode before the window is created. 16 | func checkSingleton() (windows.Handle, error) { 17 | path, err := os.Executable() 18 | if err != nil { 19 | return 0, err 20 | } 21 | hashName := md5.Sum([]byte(path)) 22 | name, err := syscall.UTF16PtrFromString("Local\\" + hex.EncodeToString(hashName[:])) 23 | if err != nil { 24 | return 0, err 25 | } 26 | return windows.CreateMutex(nil, false, name) 27 | } 28 | 29 | // showMainWindow activates and brings the window of running process to the foreground. 30 | func showMainWindow() error { 31 | var windowToShow win.HWND 32 | path, err := os.Executable() 33 | if err != nil { 34 | return err 35 | } 36 | execFileInfo, err := os.Stat(path) 37 | if err != nil { 38 | return err 39 | } 40 | syscall.MustLoadDLL("user32.dll").MustFindProc("EnumWindows").Call( 41 | syscall.NewCallback(func(hwnd syscall.Handle, lparam uintptr) uintptr { 42 | className := make([]uint16, windows.MAX_PATH) 43 | if _, err = win.GetClassName(win.HWND(hwnd), &className[0], len(className)); err != nil { 44 | return 1 45 | } 46 | if windows.UTF16ToString(className) == "\\o/ Walk_MainWindow_Class \\o/" { 47 | var pid uint32 48 | var imageName string 49 | var imageFileInfo fs.FileInfo 50 | if _, err = windows.GetWindowThreadProcessId(windows.HWND(hwnd), &pid); err != nil { 51 | return 1 52 | } 53 | imageName, err = getImageName(pid) 54 | if err != nil { 55 | return 1 56 | } 57 | imageFileInfo, err = os.Stat(imageName) 58 | if err != nil { 59 | return 1 60 | } 61 | if os.SameFile(execFileInfo, imageFileInfo) { 62 | windowToShow = win.HWND(hwnd) 63 | return 0 64 | } 65 | } 66 | return 1 67 | }), 0) 68 | if windowToShow != 0 { 69 | if win.IsIconic(windowToShow) { 70 | win.ShowWindow(windowToShow, win.SW_RESTORE) 71 | } else { 72 | win.SetForegroundWindow(windowToShow) 73 | } 74 | } 75 | return nil 76 | } 77 | 78 | // getImageName returns the full process image name of the given process id. 79 | func getImageName(pid uint32) (string, error) { 80 | proc, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid) 81 | if err != nil { 82 | return "", err 83 | } 84 | defer windows.CloseHandle(proc) 85 | var exeNameBuf [261]uint16 86 | exeNameLen := uint32(len(exeNameBuf) - 1) 87 | err = windows.QueryFullProcessImageName(proc, 0, &exeNameBuf[0], &exeNameLen) 88 | if err != nil { 89 | return "", err 90 | } 91 | return windows.UTF16ToString(exeNameBuf[:exeNameLen]), nil 92 | } 93 | -------------------------------------------------------------------------------- /docs/donate-wechat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koho/frpmgr/5b48c95d9106eae1b9d8a9b79fa77cd93fec0a22/docs/donate-wechat.jpg -------------------------------------------------------------------------------- /docs/screenshot_en.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koho/frpmgr/5b48c95d9106eae1b9d8a9b79fa77cd93fec0a22/docs/screenshot_en.png -------------------------------------------------------------------------------- /docs/screenshot_zh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koho/frpmgr/5b48c95d9106eae1b9d8a9b79fa77cd93fec0a22/docs/screenshot_zh.png -------------------------------------------------------------------------------- /generate.go: -------------------------------------------------------------------------------- 1 | package frpmgr 2 | 3 | //go:generate go run resource.go 4 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/koho/frpmgr 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/Microsoft/go-winio v0.6.2 7 | github.com/fatedier/frp v0.62.1 8 | github.com/fatedier/golib v0.5.1 9 | github.com/fsnotify/fsnotify v1.9.0 10 | github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 11 | github.com/lxn/win v0.0.0-20210218163916-a377121e959e 12 | github.com/pelletier/go-toml/v2 v2.2.0 13 | github.com/samber/lo v1.47.0 14 | golang.org/x/sys v0.32.0 15 | golang.org/x/text v0.24.0 16 | gopkg.in/ini.v1 v1.67.0 17 | ) 18 | 19 | require ( 20 | github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect 21 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 // indirect 22 | github.com/coreos/go-oidc/v3 v3.14.1 // indirect 23 | github.com/go-jose/go-jose/v4 v4.0.5 // indirect 24 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 25 | github.com/golang/snappy v0.0.4 // indirect 26 | github.com/google/pprof v0.0.0-20241206021119-61a79c692802 // indirect 27 | github.com/gorilla/mux v1.8.1 // indirect 28 | github.com/hashicorp/yamux v0.1.1 // indirect 29 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 30 | github.com/klauspost/cpuid/v2 v2.2.6 // indirect 31 | github.com/klauspost/reedsolomon v1.12.0 // indirect 32 | github.com/onsi/ginkgo/v2 v2.22.0 // indirect 33 | github.com/pion/dtls/v2 v2.2.7 // indirect 34 | github.com/pion/logging v0.2.2 // indirect 35 | github.com/pion/stun/v2 v2.0.0 // indirect 36 | github.com/pion/transport/v2 v2.2.1 // indirect 37 | github.com/pion/transport/v3 v3.0.1 // indirect 38 | github.com/pires/go-proxyproto v0.7.0 // indirect 39 | github.com/pkg/errors v0.9.1 // indirect 40 | github.com/quic-go/quic-go v0.48.2 // indirect 41 | github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 // indirect 42 | github.com/spf13/cobra v1.8.0 // indirect 43 | github.com/spf13/pflag v1.0.5 // indirect 44 | github.com/templexxx/cpu v0.1.1 // indirect 45 | github.com/templexxx/xorsimd v0.4.3 // indirect 46 | github.com/tjfoc/gmsm v1.4.1 // indirect 47 | github.com/vishvananda/netlink v1.3.0 // indirect 48 | github.com/vishvananda/netns v0.0.4 // indirect 49 | github.com/xtaci/kcp-go/v5 v5.6.13 // indirect 50 | go.uber.org/mock v0.5.0 // indirect 51 | golang.org/x/crypto v0.37.0 // indirect 52 | golang.org/x/exp v0.0.0-20241204233417-43b7b7cde48d // indirect 53 | golang.org/x/mod v0.22.0 // indirect 54 | golang.org/x/net v0.39.0 // indirect 55 | golang.org/x/oauth2 v0.28.0 // indirect 56 | golang.org/x/sync v0.13.0 // indirect 57 | golang.org/x/time v0.5.0 // indirect 58 | golang.org/x/tools v0.28.0 // indirect 59 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect 60 | golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect 61 | gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect 62 | gopkg.in/yaml.v2 v2.4.0 // indirect 63 | k8s.io/apimachinery v0.28.8 // indirect 64 | k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect 65 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 66 | sigs.k8s.io/yaml v1.3.0 // indirect 67 | ) 68 | 69 | replace github.com/lxn/walk => github.com/koho/frpmgr v0.0.0-20250417143351-c1c196166836 70 | -------------------------------------------------------------------------------- /i18n/text.go: -------------------------------------------------------------------------------- 1 | package i18n 2 | 3 | //go:generate go run golang.org/x/text/cmd/gotext -srclang=en-US update -out=catalog.go -lang=en-US,zh-CN,zh-TW,ja-JP,ko-KR,es-ES ../cmd/frpmgr 4 | 5 | import ( 6 | "encoding/json" 7 | "os" 8 | 9 | "golang.org/x/sys/windows" 10 | "golang.org/x/text/language" 11 | "golang.org/x/text/message" 12 | 13 | "github.com/koho/frpmgr/pkg/config" 14 | ) 15 | 16 | var ( 17 | printer *message.Printer 18 | useLang language.Tag 19 | IDToName = map[string]string{ 20 | "zh-CN": "简体中文", 21 | "zh-TW": "繁體中文", 22 | "en-US": "English", 23 | "ja-JP": "日本語", 24 | "ko-KR": "한국어", 25 | "es-ES": "Español", 26 | } 27 | ) 28 | 29 | func init() { 30 | if preferredLang := langInConfig(); preferredLang != "" { 31 | useLang = language.Make(preferredLang) 32 | } else { 33 | useLang = lang() 34 | } 35 | printer = message.NewPrinter(useLang) 36 | } 37 | 38 | // GetLanguage returns the current display language code. 39 | func GetLanguage() string { 40 | return useLang.String() 41 | } 42 | 43 | // langInConfig returns the UI language code in config file 44 | func langInConfig() string { 45 | b, err := os.ReadFile(config.DefaultAppFile) 46 | if err != nil { 47 | return "" 48 | } 49 | var s struct { 50 | Lang string `json:"lang"` 51 | } 52 | if err = json.Unmarshal(b, &s); err != nil { 53 | return "" 54 | } 55 | return s.Lang 56 | } 57 | 58 | // lang returns the user preferred UI language. 59 | func lang() (tag language.Tag) { 60 | tag = language.English 61 | languages, err := windows.GetUserPreferredUILanguages(windows.MUI_LANGUAGE_NAME) 62 | if err != nil { 63 | return 64 | } 65 | if match := message.MatchLanguage(languages...); !match.IsRoot() { 66 | tag = match 67 | } 68 | return 69 | } 70 | 71 | // Sprintf is just a wrapper function of message printer. 72 | func Sprintf(key message.Reference, a ...interface{}) string { 73 | return printer.Sprintf(key, a...) 74 | } 75 | 76 | // SprintfColon adds a colon at the tail of a string. 77 | func SprintfColon(key message.Reference, a ...interface{}) string { 78 | return Sprintf(key, a...) + ":" 79 | } 80 | 81 | // SprintfEllipsis adds an ellipsis at the tail of a string. 82 | func SprintfEllipsis(key message.Reference, a ...interface{}) string { 83 | return Sprintf(key, a...) + "..." 84 | } 85 | 86 | // SprintfLSpace adds a space at the start of a string. 87 | func SprintfLSpace(key message.Reference, a ...interface{}) string { 88 | return " " + Sprintf(key, a...) 89 | } 90 | 91 | // SprintfRSpace adds a space at the end of a string. 92 | func SprintfRSpace(key message.Reference, a ...interface{}) string { 93 | return Sprintf(key, a...) + " " 94 | } 95 | -------------------------------------------------------------------------------- /icon/app.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koho/frpmgr/5b48c95d9106eae1b9d8a9b79fa77cd93fec0a22/icon/app.ico -------------------------------------------------------------------------------- /icon/app.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /icon/dot.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/koho/frpmgr/5b48c95d9106eae1b9d8a9b79fa77cd93fec0a22/icon/dot.ico -------------------------------------------------------------------------------- /icon/dot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /installer/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /installer/actions/.gitignore: -------------------------------------------------------------------------------- 1 | .vs 2 | bin 3 | obj -------------------------------------------------------------------------------- /installer/actions/actions.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.30717.126 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "actions", "actions\actions.csproj", "{DC9743DA-8782-4A7C-8B46-B2D4EEA19D4E}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|x64 = Debug|x64 11 | Debug|x86 = Debug|x86 12 | Release|x64 = Release|x64 13 | Release|x86 = Release|x86 14 | EndGlobalSection 15 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 16 | {DC9743DA-8782-4A7C-8B46-B2D4EEA19D4E}.Debug|x64.ActiveCfg = Debug|x64 17 | {DC9743DA-8782-4A7C-8B46-B2D4EEA19D4E}.Debug|x64.Build.0 = Debug|x64 18 | {DC9743DA-8782-4A7C-8B46-B2D4EEA19D4E}.Debug|x86.ActiveCfg = Debug|x86 19 | {DC9743DA-8782-4A7C-8B46-B2D4EEA19D4E}.Debug|x86.Build.0 = Debug|x86 20 | {DC9743DA-8782-4A7C-8B46-B2D4EEA19D4E}.Release|x64.ActiveCfg = Release|x64 21 | {DC9743DA-8782-4A7C-8B46-B2D4EEA19D4E}.Release|x64.Build.0 = Release|x64 22 | {DC9743DA-8782-4A7C-8B46-B2D4EEA19D4E}.Release|x86.ActiveCfg = Release|x86 23 | {DC9743DA-8782-4A7C-8B46-B2D4EEA19D4E}.Release|x86.Build.0 = Release|x86 24 | EndGlobalSection 25 | GlobalSection(SolutionProperties) = preSolution 26 | HideSolutionNode = FALSE 27 | EndGlobalSection 28 | GlobalSection(ExtensibilityGlobals) = postSolution 29 | SolutionGuid = {9B84C910-F22D-4D3F-9BD6-6C2134E26EE8} 30 | EndGlobalSection 31 | EndGlobal 32 | -------------------------------------------------------------------------------- /installer/actions/actions/CustomAction.config: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /installer/actions/actions/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using System.Runtime.CompilerServices; 3 | using System.Runtime.InteropServices; 4 | 5 | // General Information about an assembly is controlled through the following 6 | // set of attributes. Change these attribute values to modify the information 7 | // associated with an assembly. 8 | [assembly: AssemblyTitle("actions")] 9 | [assembly: AssemblyDescription("")] 10 | [assembly: AssemblyCompany("")] 11 | [assembly: AssemblyProduct("actions")] 12 | [assembly: AssemblyCopyright("Copyright © 2020")] 13 | [assembly: AssemblyTrademark("")] 14 | [assembly: AssemblyCulture("")] 15 | 16 | // Setting ComVisible to false makes the types in this assembly not visible 17 | // to COM components. If you need to access a type in this assembly from 18 | // COM, set the ComVisible attribute to true on that type. 19 | [assembly: ComVisible(false)] 20 | 21 | // The following GUID is for the ID of the typelib if this project is exposed to COM 22 | [assembly: Guid("dc9743da-8782-4a7c-8b46-b2d4eea19d4e")] 23 | 24 | // Version information for an assembly consists of the following four values: 25 | // 26 | // Major Version 27 | // Minor Version 28 | // Build Number 29 | // Revision 30 | // 31 | // You can specify all the values or you can default the Build and Revision Numbers 32 | // by using the '*' as shown below: 33 | // [assembly: AssemblyVersion("1.0.*")] 34 | [assembly: AssemblyVersion("1.0.0.0")] 35 | [assembly: AssemblyFileVersion("1.0.0.0")] 36 | -------------------------------------------------------------------------------- /installer/actions/actions/actions.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Debug 5 | x86 6 | 8.0.30703 7 | 2.0 8 | {DC9743DA-8782-4A7C-8B46-B2D4EEA19D4E} 9 | Library 10 | Properties 11 | actions 12 | actions 13 | v3.5 14 | 512 15 | 16 | 17 | true 18 | full 19 | false 20 | bin\Debug\ 21 | DEBUG;TRACE 22 | prompt 23 | 4 24 | 25 | 26 | pdbonly 27 | true 28 | bin\x86\Release\ 29 | TRACE 30 | prompt 31 | 4 32 | 33 | 34 | true 35 | bin\x64\Debug\ 36 | DEBUG;TRACE 37 | full 38 | x64 39 | 7.3 40 | prompt 41 | 42 | 43 | bin\x64\Release\ 44 | TRACE 45 | true 46 | pdbonly 47 | x64 48 | 7.3 49 | prompt 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | True 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /installer/build.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal enabledelayedexpansion 3 | set VERSION=%1 4 | set BUILDDIR=%~dp0 5 | set RESx64=pe-x86-64 6 | set RESx86=pe-i386 7 | cd /d %BUILDDIR% || exit /b 1 8 | 9 | if "%WIX%"=="" ( 10 | echo ERROR: WIX was not found. 11 | exit /b 1 12 | ) 13 | 14 | :build 15 | if not exist build md build 16 | for %%a in (%ARCHS%) do ( 17 | set PLAT=%%a 18 | call :build_plat x!PLAT:~-2! || goto :error 19 | ) 20 | 21 | :success 22 | exit /b 0 23 | 24 | :build_plat 25 | set ARCH=%~1 26 | call vcvarsall.bat %ARCH% 27 | set PLAT_DIR=build\%ARCH% 28 | if not exist %PLAT_DIR% md %PLAT_DIR% 29 | 30 | msbuild actions\actions.sln /t:Rebuild /p:Configuration=Release /p:Platform="%ARCH%" || goto :error 31 | copy actions\actions\bin\%ARCH%\Release\actions.CA.dll %PLAT_DIR%\actions.dll /y || goto :error 32 | 33 | set MSI_FILE=%PLAT_DIR%\frpmgr-%VERSION%.msi 34 | set WIX_CANDLE_FLAGS=-dVERSION=%VERSION% 35 | set WIX_LIGHT_FLAGS=-ext "%WIX%bin\WixUtilExtension.dll" -ext "%WIX%bin\WixUIExtension.dll" -sval 36 | set WIX_OBJ=%PLAT_DIR%\frpmgr.wixobj 37 | "%WIX%bin\candle" %WIX_CANDLE_FLAGS% -out %WIX_OBJ% -arch %ARCH% msi\frpmgr.wxs || goto :error 38 | "%WIX%bin\light" %WIX_LIGHT_FLAGS% -cultures:en-US -loc msi\en-US.wxl -out %MSI_FILE% %WIX_OBJ% || goto :error 39 | for %%l in (zh-CN zh-TW ja-JP ko-KR es-ES) do ( 40 | set WIX_LANG_MSI=%MSI_FILE:~0,-4%_%%l.msi 41 | "%WIX%bin\light" %WIX_LIGHT_FLAGS% -cultures:%%l -loc msi\%%l.wxl -out !WIX_LANG_MSI! %WIX_OBJ% || goto :error 42 | for /f "tokens=3 delims=><" %%a in ('findstr /r "Id.*=.*Language" msi\%%l.wxl') do set LANG_CODE=%%a 43 | "%WindowsSdkVerBinPath%x86\MsiTran" -g %MSI_FILE% !WIX_LANG_MSI! %PLAT_DIR%\!LANG_CODE! || goto :error 44 | "%WindowsSdkVerBinPath%x86\MsiDb" -d %MSI_FILE% -r %PLAT_DIR%\!LANG_CODE! || goto :error 45 | ) 46 | windres -DARCH=%ARCH% -DVERSION_ARRAY=%VERSION:.=,% -DVERSION_STR=%VERSION% -DMSI_FILE=%MSI_FILE:\=\\% -i setup\resource.rc -o %PLAT_DIR%\rsrc.o -O coff -c 65001 -F !RES%ARCH%! || goto :error 47 | cl /Fe..\bin\frpmgr-%VERSION%-setup-%ARCH%.exe /Fo%PLAT_DIR%\setup.obj /utf-8 setup\setup.c /link /subsystem:windows %PLAT_DIR%\rsrc.o shlwapi.lib msi.lib user32.lib advapi32.lib 48 | goto :eof 49 | 50 | :error 51 | exit /b %errorlevel% 52 | -------------------------------------------------------------------------------- /installer/msi/en-US.wxl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 1033 4 | FRP Manager 5 | This application is only supported on Windows 10, Windows Server 2016, or higher. 6 | 7 | -------------------------------------------------------------------------------- /installer/msi/es-ES.wxl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 3082 4 | Administrador de FRP 5 | Esta aplicación solo es compatible con Windows 10, Windows Server 2016 o superior. 6 | 7 | -------------------------------------------------------------------------------- /installer/msi/frpmgr.wxs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | = 603)]]> 22 | 23 | 24 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 41 | 42 | WIX_UPGRADE_DETECTED 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 1 58 | 1 59 | 60 | 61 | NOT Installed 62 | 63 | 64 | 65 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 107 | 108 | 109 | 110 | 113 | 114 | 115 | (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL") 116 | 117 | 118 | 121 | 122 | 123 | 124 | (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL") 125 | 126 | 127 | 130 | 131 | 132 | 133 | REMOVE="ALL" 134 | 135 | 136 | 139 | 140 | 141 | 142 | (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL") 143 | 144 | 145 | 148 | 149 | 150 | 151 | NOT (REMOVE="ALL") 152 | 153 | 154 | 157 | 158 | 159 | 160 | NOT (REMOVE="ALL") 161 | 162 | 163 | 166 | 167 | 168 | 169 | NOT (REMOVE="ALL") 170 | 171 | 172 | 173 | -------------------------------------------------------------------------------- /installer/msi/ja-JP.wxl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 1041 4 | FRP マネージャ 5 | このアプリケーションは、Windows 10、Windows Server 2016 以降でのみサポートされています。 6 | 7 | -------------------------------------------------------------------------------- /installer/msi/ko-KR.wxl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 1042 4 | FRP 관리자 5 | 이 애플리케이션은 Windows 10, Windows Server 2016 이상에서만 지원됩니다. 6 | 7 | -------------------------------------------------------------------------------- /installer/msi/zh-CN.wxl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 2052 4 | FRP 管理器 5 | 此应用程序仅在 Windows 10、Windows Server 2016 或更高版本上受支持。 6 | 7 | -------------------------------------------------------------------------------- /installer/msi/zh-TW.wxl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 1028 4 | FRP 管理器 5 | 此應用程式僅在 Windows 10、Windows Server 2016 或更高版本上支援。 6 | 7 | -------------------------------------------------------------------------------- /installer/setup/manifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | PerMonitorV2, PerMonitor 19 | True 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /installer/setup/resource.h: -------------------------------------------------------------------------------- 1 | #ifndef _RESOURCE_H 2 | #define _RESOURCE_H 3 | 4 | #define IDI_ICON 10 5 | #define IDR_MSI 11 6 | #define IDD_LANG_DIALOG 100 7 | #define IDC_LANG_COMBO 1000 8 | #define IDC_STATIC -1 9 | 10 | #endif 11 | -------------------------------------------------------------------------------- /installer/setup/resource.rc: -------------------------------------------------------------------------------- 1 | #include 2 | #include "resource.h" 3 | 4 | #pragma code_page(65001) // UTF-8 5 | 6 | #define STRINGIZE(x) #x 7 | #define EXPAND(x) STRINGIZE(x) 8 | #define TITLE "FRP Manager Setup" 9 | 10 | LANGUAGE LANG_NEUTRAL, SUBLANG_NEUTRAL 11 | CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST manifest.xml 12 | IDI_ICON ICON "../icon/app.ico" 13 | IDR_MSI RCDATA EXPAND(MSI_FILE) 14 | 15 | #define VERSIONINFO_TEMPLATE(block_id, lang_id, charset_id, file_desc, product_name) \ 16 | VS_VERSION_INFO VERSIONINFO \ 17 | FILEVERSION VERSION_ARRAY \ 18 | PRODUCTVERSION VERSION_ARRAY \ 19 | FILEFLAGSMASK VS_FFI_FILEFLAGSMASK \ 20 | FILEFLAGS 0x0 \ 21 | FILEOS VOS__WINDOWS32 \ 22 | FILETYPE VFT_APP \ 23 | FILESUBTYPE VFT2_UNKNOWN \ 24 | BEGIN \ 25 | BLOCK "StringFileInfo" \ 26 | BEGIN \ 27 | BLOCK block_id \ 28 | BEGIN \ 29 | VALUE "CompanyName", "FRP Manager Project" \ 30 | VALUE "FileDescription", file_desc \ 31 | VALUE "FileVersion", EXPAND(VERSION_STR) \ 32 | VALUE "InternalName", "frpmgr-Setup" \ 33 | VALUE "LegalCopyright", "Copyright © FRP Manager Project" \ 34 | VALUE "OriginalFilename", EXPAND(frpmgr-VERSION_STR-setup-ARCH.exe) \ 35 | VALUE "ProductName", product_name \ 36 | VALUE "ProductVersion", EXPAND(VERSION_STR) \ 37 | VALUE "Comments", "https://github.com/koho/frpmgr" \ 38 | END \ 39 | END \ 40 | BLOCK "VarFileInfo" \ 41 | BEGIN \ 42 | VALUE "Translation", lang_id, charset_id \ 43 | END \ 44 | END 45 | 46 | LANGUAGE LANG_ENGLISH, SUBLANG_DEFAULT 47 | VERSIONINFO_TEMPLATE( 48 | "040904B0", 0x0409, 1200, 49 | TITLE, 50 | "FRP Manager" 51 | ) 52 | 53 | LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_SIMPLIFIED 54 | VERSIONINFO_TEMPLATE( 55 | "080404B0", 0x0804, 1200, 56 | "FRP 管理器安装程序", 57 | "FRP 管理器" 58 | ) 59 | 60 | LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_TRADITIONAL 61 | VERSIONINFO_TEMPLATE( 62 | "040404B0", 0x0404, 1200, 63 | "FRP 管理器安裝程式", 64 | "FRP 管理器" 65 | ) 66 | 67 | LANGUAGE LANG_JAPANESE, SUBLANG_DEFAULT 68 | VERSIONINFO_TEMPLATE( 69 | "041104B0", 0x0411, 1200, 70 | "FRP マネージャーインストーラー", 71 | "FRP マネージャ" 72 | ) 73 | 74 | LANGUAGE LANG_KOREAN, SUBLANG_DEFAULT 75 | VERSIONINFO_TEMPLATE( 76 | "041204B0", 0x0412, 1200, 77 | "FRP 관리자 설치 프로그램", 78 | "FRP 관리자" 79 | ) 80 | 81 | LANGUAGE LANG_SPANISH, SUBLANG_SPANISH 82 | VERSIONINFO_TEMPLATE( 83 | "0C0A04B0", 0x0C0A, 1200, 84 | "Instalación de Administrador de FRP", 85 | "Administrador de FRP" 86 | ) 87 | 88 | LANGUAGE LANG_NEUTRAL, SUBLANG_NEUTRAL 89 | IDD_LANG_DIALOG DIALOGEX 0, 0, 252, 79 90 | STYLE DS_SETFONT | DS_MODALFRAME | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU 91 | CAPTION TITLE 92 | FONT 8, "Tahoma" 93 | BEGIN 94 | COMBOBOX IDC_LANG_COMBO, 34, 40, 211, 374, CBS_DROPDOWNLIST | CBS_AUTOHSCROLL | WS_CHILD | WS_VISIBLE | WS_VSCROLL | WS_TABSTOP 95 | DEFPUSHBUTTON "OK", IDOK, 141, 58, 50, 14, BS_DEFPUSHBUTTON | WS_CHILD | WS_VISIBLE | WS_TABSTOP 96 | PUSHBUTTON "Cancel", IDCANCEL, 195, 58, 50, 14, BS_PUSHBUTTON | WS_CHILD | WS_VISIBLE | WS_TABSTOP 97 | LTEXT "Select the language for the installation from the choices below.", IDC_STATIC, 34, 8, 211, 25, SS_LEFT | SS_NOPREFIX | WS_CHILD | WS_VISIBLE | WS_GROUP 98 | ICON IDI_ICON, IDC_STATIC, 7, 7, 21, 20, SS_ICON | WS_CHILD | WS_VISIBLE 99 | END 100 | -------------------------------------------------------------------------------- /installer/setup/setup.c: -------------------------------------------------------------------------------- 1 | #ifndef UNICODE 2 | #define UNICODE 3 | #endif 4 | 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | #include "resource.h" 12 | 13 | typedef struct { 14 | LCID id; 15 | TCHAR name[16]; 16 | char code[10]; 17 | } Language; 18 | 19 | static TCHAR msiFile[MAX_PATH]; 20 | static HANDLE hFile = INVALID_HANDLE_VALUE; 21 | static Language languages[] = { 22 | {2052, TEXT("简体中文"), "zh-CN"}, 23 | {1028, TEXT("繁體中文"), "zh-TW"}, 24 | {1033, TEXT("English"), "en-US"}, 25 | {1041, TEXT("日本語"), "ja-JP"}, 26 | {1042, TEXT("한국어"), "ko-KR"}, 27 | {3082, TEXT("Español"), "es-ES"}, 28 | }; 29 | 30 | static BOOL RandomString(TCHAR ss[32]) { 31 | uint8_t bytes[32]; 32 | if (!RtlGenRandom(bytes, sizeof(bytes))) 33 | return FALSE; 34 | for (int i = 0; i < 31; ++i) { 35 | ss[i] = (TCHAR) (bytes[i] % 26 + 97); 36 | } 37 | ss[31] = '\0'; 38 | return TRUE; 39 | } 40 | 41 | static int Cleanup(void) { 42 | if (hFile != INVALID_HANDLE_VALUE) { 43 | for (int i = 0; i < 200 && !DeleteFile(msiFile) && GetLastError() != ERROR_FILE_NOT_FOUND; ++i) 44 | Sleep(200); 45 | } 46 | return 0; 47 | } 48 | 49 | static Language *GetPreferredLang(TCHAR *folder) { 50 | TCHAR langPath[MAX_PATH]; 51 | if (PathCombine(langPath, folder, L"app.json") == NULL) { 52 | return NULL; 53 | } 54 | FILE *file; 55 | if (_wfopen_s(&file, langPath, L"rb") != 0) { 56 | return NULL; 57 | } 58 | fseek(file, 0L, SEEK_END); 59 | long fileSize = ftell(file); 60 | fseek(file, 0L, SEEK_SET); 61 | char *buf = malloc(fileSize + 1); 62 | size_t size = fread(buf, 1, fileSize, file); 63 | buf[size] = 0; 64 | fclose(file); 65 | const char *p1 = strstr(buf, "\"lang\""); 66 | if (p1 == NULL) { 67 | goto cleanup; 68 | } 69 | const char *p2 = strstr(p1, ":"); 70 | if (p2 == NULL) { 71 | goto cleanup; 72 | } 73 | const char *p3 = strstr(p2, "\""); 74 | if (p3 == NULL) { 75 | goto cleanup; 76 | } 77 | for (int i = 0; i < sizeof(languages) / sizeof(languages[0]); i++) { 78 | if (strncmp(p3 + 1, languages[i].code, strlen(languages[i].code)) == 0) { 79 | free(buf); 80 | return &languages[i]; 81 | } 82 | } 83 | cleanup: 84 | free(buf); 85 | return NULL; 86 | } 87 | 88 | INT_PTR CALLBACK LangDialog(HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam) { 89 | switch (message) { 90 | case WM_INITDIALOG: 91 | for (int i = 0; i < sizeof(languages) / sizeof(languages[0]); i++) { 92 | SendDlgItemMessage(hDlg, IDC_LANG_COMBO, CB_ADDSTRING, 0, (LPARAM) languages[i].name); 93 | } 94 | SendDlgItemMessage(hDlg, IDC_LANG_COMBO, CB_SETCURSEL, 0, 0); 95 | return (INT_PTR) TRUE; 96 | 97 | case WM_COMMAND: 98 | if (LOWORD(wParam) == IDOK || LOWORD(wParam) == IDCANCEL) { 99 | INT_PTR nResult = LOWORD(wParam); 100 | if (LOWORD(wParam) == IDOK) { 101 | int idx = SendDlgItemMessage(hDlg, IDC_LANG_COMBO, CB_GETCURSEL, 0, 0); 102 | nResult = (INT_PTR) &languages[idx]; 103 | } 104 | EndDialog(hDlg, nResult); 105 | return (INT_PTR) TRUE; 106 | } 107 | break; 108 | } 109 | return (INT_PTR) FALSE; 110 | } 111 | 112 | int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, PSTR pCmdLine, int nCmdShow) { 113 | _onexit(Cleanup); 114 | // Retrieve install location 115 | TCHAR installPath[MAX_PATH]; 116 | DWORD dwSize = MAX_PATH; 117 | memset(installPath, 0, dwSize); 118 | Language *lang = NULL; 119 | if (MsiLocateComponent(L"{E39EABEF-A7EB-4EAF-AD3E-A1254450BBE1}", installPath, &dwSize) >= 0 && wcslen(installPath) > 0) { 120 | PathRemoveFileSpec(installPath); 121 | lang = GetPreferredLang(installPath); 122 | } 123 | if (lang == NULL) { 124 | INT_PTR nResult = DialogBox(hInstance, MAKEINTRESOURCE(IDD_LANG_DIALOG), NULL, LangDialog); 125 | if (nResult == IDCANCEL) { 126 | return 0; 127 | } 128 | lang = (Language *) nResult; 129 | } 130 | TCHAR randFile[32]; 131 | if (!GetWindowsDirectory(msiFile, sizeof(msiFile)) || !PathAppend(msiFile, L"Temp")) 132 | return 1; 133 | if (!RandomString(randFile)) 134 | return 1; 135 | if (!PathAppend(msiFile, randFile)) 136 | return 1; 137 | HRSRC hRes = FindResource(NULL, MAKEINTRESOURCE(IDR_MSI), RT_RCDATA); 138 | if (hRes == NULL) { 139 | return 1; 140 | } 141 | HGLOBAL msiData = LoadResource(NULL, hRes); 142 | if (msiData == NULL) { 143 | return 1; 144 | } 145 | DWORD msiSize = SizeofResource(NULL, hRes); 146 | if (msiSize == 0) { 147 | return 1; 148 | } 149 | LPVOID pMsiData = LockResource(msiData); 150 | if (pMsiData == NULL) { 151 | return 1; 152 | } 153 | SECURITY_ATTRIBUTES security_attributes = {.nLength = sizeof(security_attributes)}; 154 | hFile = CreateFile(msiFile, GENERIC_WRITE | DELETE, 0, &security_attributes, CREATE_NEW, 155 | FILE_ATTRIBUTE_TEMPORARY, NULL); 156 | if (hFile == INVALID_HANDLE_VALUE) { 157 | return 1; 158 | } 159 | DWORD bytesWritten; 160 | if (!WriteFile(hFile, pMsiData, msiSize, &bytesWritten, NULL) || bytesWritten != msiSize) { 161 | CloseHandle(hFile); 162 | return 1; 163 | } 164 | CloseHandle(hFile); 165 | MsiSetInternalUI(INSTALLUILEVEL_FULL, NULL); 166 | TCHAR cmd[500]; 167 | wsprintf(cmd, L"ProductLanguage=%d PREVINSTALLFOLDER=\"%s\"", lang->id, installPath); 168 | return MsiInstallProduct(msiFile, cmd); 169 | } 170 | -------------------------------------------------------------------------------- /pkg/config/app.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | 7 | "github.com/koho/frpmgr/pkg/consts" 8 | ) 9 | 10 | const DefaultAppFile = "app.json" 11 | 12 | type App struct { 13 | Lang string `json:"lang,omitempty"` 14 | Password string `json:"password,omitempty"` 15 | Defaults DefaultValue `json:"defaults"` 16 | Sort []string `json:"sort,omitempty"` 17 | } 18 | 19 | type DefaultValue struct { 20 | Protocol string `json:"protocol,omitempty"` 21 | User string `json:"user,omitempty"` 22 | LogLevel string `json:"logLevel"` 23 | LogMaxDays int64 `json:"logMaxDays"` 24 | DeleteAfterDays int64 `json:"deleteAfterDays,omitempty"` 25 | DNSServer string `json:"dnsServer,omitempty"` 26 | NatHoleSTUNServer string `json:"natHoleStunServer,omitempty"` 27 | ConnectServerLocalIP string `json:"connectServerLocalIP,omitempty"` 28 | TCPMux bool `json:"tcpMux"` 29 | TLSEnable bool `json:"tls"` 30 | ManualStart bool `json:"manualStart,omitempty"` 31 | LegacyFormat bool `json:"legacyFormat,omitempty"` 32 | } 33 | 34 | func (dv *DefaultValue) AsClientConfig() ClientCommon { 35 | conf := ClientCommon{ 36 | ServerPort: consts.DefaultServerPort, 37 | Protocol: dv.Protocol, 38 | User: dv.User, 39 | LogLevel: dv.LogLevel, 40 | LogMaxDays: dv.LogMaxDays, 41 | DNSServer: dv.DNSServer, 42 | NatHoleSTUNServer: dv.NatHoleSTUNServer, 43 | ConnectServerLocalIP: dv.ConnectServerLocalIP, 44 | TCPMux: dv.TCPMux, 45 | TLSEnable: dv.TLSEnable, 46 | ManualStart: dv.ManualStart, 47 | LegacyFormat: dv.LegacyFormat, 48 | DisableCustomTLSFirstByte: true, 49 | } 50 | if dv.DeleteAfterDays > 0 { 51 | conf.AutoDelete = AutoDelete{ 52 | DeleteMethod: consts.DeleteRelative, 53 | DeleteAfterDays: dv.DeleteAfterDays, 54 | } 55 | } 56 | return conf 57 | } 58 | 59 | func UnmarshalAppConf(path string, dst *App) error { 60 | b, err := os.ReadFile(path) 61 | if err != nil { 62 | return err 63 | } 64 | return json.Unmarshal(b, dst) 65 | } 66 | 67 | func (conf *App) Save(path string) error { 68 | b, err := json.MarshalIndent(conf, "", " ") 69 | if err != nil { 70 | return err 71 | } 72 | return os.WriteFile(path, b, 0666) 73 | } 74 | -------------------------------------------------------------------------------- /pkg/config/app_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestUnmarshalAppConfFromIni(t *testing.T) { 10 | input := `{ 11 | "password": "abcde", 12 | "defaults": { 13 | "logLevel": "info", 14 | "logMaxDays": 5, 15 | "protocol": "kcp", 16 | "user": "user", 17 | "tcpMux": true, 18 | "manualStart": true, 19 | "deleteAfterDays": 1, 20 | "legacyFormat": true 21 | } 22 | } 23 | ` 24 | if err := os.WriteFile(DefaultAppFile, []byte(input), 0666); err != nil { 25 | t.Fatal(err) 26 | } 27 | expected := App{ 28 | Password: "abcde", 29 | Defaults: DefaultValue{ 30 | LogLevel: "info", 31 | LogMaxDays: 5, 32 | Protocol: "kcp", 33 | User: "user", 34 | TCPMux: true, 35 | ManualStart: true, 36 | DeleteAfterDays: 1, 37 | LegacyFormat: true, 38 | }, 39 | } 40 | var actual App 41 | if err := UnmarshalAppConf(DefaultAppFile, &actual); err != nil { 42 | t.Fatal(err) 43 | } 44 | if !reflect.DeepEqual(actual, expected) { 45 | t.Errorf("Expected: %v, got: %v", expected, actual) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pkg/config/client_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestUnmarshalClientConfFromIni(t *testing.T) { 10 | input := ` 11 | [common] 12 | server_addr = example.com 13 | server_port = 7001 14 | token = 123456 15 | frpmgr_manual_start = true 16 | frpmgr_delete_method = absolute 17 | frpmgr_delete_after_date = 2023-03-23T00:00:00Z 18 | meta_1 = value 19 | 20 | [ssh] 21 | type = tcp 22 | local_ip = 192.168.1.1 23 | local_port = 22 24 | remote_port = 6000 25 | meta_2 = value 26 | ` 27 | expected := NewDefaultClientConfig() 28 | expected.LegacyFormat = true 29 | expected.ServerAddress = "example.com" 30 | expected.ServerPort = 7001 31 | expected.Token = "123456" 32 | expected.ManualStart = true 33 | expected.Metas = map[string]string{"1": "value"} 34 | expected.DeleteMethod = "absolute" 35 | expected.DeleteAfterDate = time.Date(2023, 3, 23, 0, 0, 0, 0, time.UTC) 36 | expected.Proxies = append(expected.Proxies, &Proxy{ 37 | BaseProxyConf: BaseProxyConf{ 38 | Name: "ssh", 39 | Type: "tcp", 40 | LocalIP: "192.168.1.1", 41 | LocalPort: "22", 42 | Metas: map[string]string{"2": "value"}, 43 | }, 44 | RemotePort: "6000", 45 | }) 46 | cc, err := UnmarshalClientConfFromIni([]byte(input)) 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | if !reflect.DeepEqual(cc, expected) { 51 | t.Errorf("Expected: %v, got: %v", expected, cc) 52 | } 53 | } 54 | 55 | func TestProxyGetAlias(t *testing.T) { 56 | input := ` 57 | [range:test_tcp] 58 | type = tcp 59 | local_ip = 127.0.0.1 60 | local_port = 6000-6006,6007 61 | remote_port = 6000-6006,6007 62 | ` 63 | expected := []string{"test_tcp_0", "test_tcp_1", "test_tcp_2", "test_tcp_3", 64 | "test_tcp_4", "test_tcp_5", "test_tcp_6", "test_tcp_7"} 65 | proxy, err := UnmarshalProxyFromIni([]byte(input)) 66 | if err != nil { 67 | t.Fatal(err) 68 | } 69 | output := proxy.GetAlias() 70 | if !reflect.DeepEqual(output, expected) { 71 | t.Errorf("Expected: %v, got: %v", expected, output) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /pkg/config/conf.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "time" 6 | 7 | "gopkg.in/ini.v1" 8 | 9 | "github.com/koho/frpmgr/pkg/consts" 10 | "github.com/koho/frpmgr/pkg/util" 11 | ) 12 | 13 | func init() { 14 | ini.PrettyFormat = false 15 | ini.PrettyEqual = true 16 | } 17 | 18 | // Config is the interface that a config must implement to support management. 19 | type Config interface { 20 | // Name of this config. 21 | Name() string 22 | // Items returns all sections in this config. which must be a slice of pointer to struct. 23 | Items() interface{} 24 | // ItemAt returns the section in this config for the given index. 25 | ItemAt(index int) interface{} 26 | // DeleteItem deletes the section for the given index. 27 | DeleteItem(index int) 28 | // AddItem adds a section to this config. 29 | AddItem(item interface{}) 30 | // Save serializes this config and saves to the given path. 31 | Save(path string) error 32 | // Complete prunes and completes this config. 33 | // When "read" is true, the config should be completed for a file loaded from source. 34 | // Otherwise, it should be completed for file written to disk. 35 | Complete(read bool) 36 | // GetLogFile returns the log file path of this config. 37 | GetLogFile() string 38 | // SetLogFile changes the log file path of this config. 39 | SetLogFile(logPath string) 40 | // AutoStart indicates whether this config should be started at boot. 41 | AutoStart() bool 42 | // Expiry indicates whether the config has an expiry date. 43 | Expiry() bool 44 | // GetSTUNServer returns the STUN server to help penetrate NAT hole. 45 | GetSTUNServer() string 46 | // Copy creates a new copy of this config. 47 | Copy(all bool) Config 48 | // Ext is the file extension of this config. 49 | Ext() string 50 | } 51 | 52 | type AutoDelete struct { 53 | // DeleteMethod specifies what delete method to use to delete the config. 54 | // If "absolute" is specified, the expiry date is set in config. If "relative" is specified, the expiry date 55 | // is calculated by adding the days to the file modification time. If it's empty, the config has no expiry date. 56 | DeleteMethod string `ini:"frpmgr_delete_method,omitempty" json:"method,omitempty"` 57 | // DeleteAfterDays is the number of days a config will be kept, after which it may be stopped and deleted. 58 | DeleteAfterDays int64 `ini:"frpmgr_delete_after_days,omitempty" relative:"true" json:"afterDays,omitempty"` 59 | // DeleteAfterDate is the last date the config will be valid, after which it may be stopped and deleted. 60 | DeleteAfterDate time.Time `ini:"frpmgr_delete_after_date,omitempty" absolute:"true" json:"afterDate,omitempty"` 61 | } 62 | 63 | func (ad AutoDelete) Complete() AutoDelete { 64 | deleteMethod := ad.DeleteMethod 65 | if deleteMethod != "" { 66 | if d, err := util.PruneByTag(ad, "true", deleteMethod); err == nil { 67 | ad = d.(AutoDelete) 68 | ad.DeleteMethod = deleteMethod 69 | } 70 | // Reset zero day 71 | if deleteMethod == consts.DeleteRelative && ad.DeleteAfterDays == 0 { 72 | ad.DeleteMethod = "" 73 | } 74 | } else { 75 | ad = AutoDelete{} 76 | } 77 | return ad 78 | } 79 | 80 | // Expiry returns the remaining duration, after which a config will expire. 81 | // If a config has no expiry date, an `ErrNoDeadline` error is returned. 82 | func Expiry(configPath string, del AutoDelete) (time.Duration, error) { 83 | fInfo, err := os.Stat(configPath) 84 | if err != nil { 85 | return 0, err 86 | } 87 | switch del.DeleteMethod { 88 | case consts.DeleteAbsolute: 89 | return time.Until(del.DeleteAfterDate), nil 90 | case consts.DeleteRelative: 91 | if del.DeleteAfterDays > 0 { 92 | elapsed := time.Since(fInfo.ModTime()) 93 | total := time.Hour * 24 * time.Duration(del.DeleteAfterDays) 94 | return total - elapsed, nil 95 | } 96 | } 97 | return 0, os.ErrNoDeadline 98 | } 99 | -------------------------------------------------------------------------------- /pkg/config/conf_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func init() { 10 | if err := os.MkdirAll("testdata", 0750); err != nil { 11 | panic(err) 12 | } 13 | if err := os.Chdir("testdata"); err != nil { 14 | panic(err) 15 | } 16 | } 17 | 18 | func TestExpiry(t *testing.T) { 19 | if err := os.WriteFile("example.ini", []byte("test"), 0666); err != nil { 20 | t.Fatal(err) 21 | } 22 | tests := []struct { 23 | input AutoDelete 24 | expected time.Duration 25 | }{ 26 | {input: AutoDelete{DeleteMethod: "relative", DeleteAfterDays: 5}, expected: 5 * time.Hour * 24}, 27 | {input: AutoDelete{DeleteMethod: "absolute", DeleteAfterDate: time.Now().AddDate(0, 0, 3)}, expected: 3 * time.Hour * 24}, 28 | } 29 | for i, test := range tests { 30 | output, err := Expiry("example.ini", test.input) 31 | if err != nil { 32 | t.Error(err) 33 | continue 34 | } 35 | if (test.expected - output).Abs() > 3*time.Second { 36 | t.Errorf("Test %d: expected: %v, got: %v", i, test.expected, output) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pkg/config/v1.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "github.com/fatedier/frp/pkg/config/v1" 7 | ) 8 | 9 | type ClientConfigV1 struct { 10 | v1.ClientCommonConfig 11 | 12 | Proxies []TypedProxyConfig `json:"proxies,omitempty"` 13 | Visitors []TypedVisitorConfig `json:"visitors,omitempty"` 14 | 15 | Mgr Mgr `json:"frpmgr,omitempty"` 16 | } 17 | 18 | type Mgr struct { 19 | Name string `json:"name,omitempty"` 20 | ManualStart bool `json:"manualStart,omitempty"` 21 | AutoDelete AutoDelete `json:"autoDelete,omitempty"` 22 | } 23 | 24 | type TypedProxyConfig struct { 25 | v1.TypedProxyConfig 26 | Mgr ProxyMgr `json:"frpmgr,omitempty"` 27 | } 28 | 29 | type TypedVisitorConfig struct { 30 | v1.TypedVisitorConfig 31 | Mgr ProxyMgr `json:"frpmgr,omitempty"` 32 | } 33 | 34 | type ProxyMgr struct { 35 | Range RangePort `json:"range,omitempty"` 36 | Sort int `json:"sort,omitempty"` 37 | } 38 | 39 | type RangePort struct { 40 | Local string `json:"local"` 41 | Remote string `json:"remote"` 42 | } 43 | 44 | func (c *TypedProxyConfig) UnmarshalJSON(b []byte) error { 45 | err := c.TypedProxyConfig.UnmarshalJSON(b) 46 | if err != nil { 47 | return err 48 | } 49 | c.Mgr, err = unmarshalProxyMgr(b) 50 | return err 51 | } 52 | 53 | func (c *TypedVisitorConfig) UnmarshalJSON(b []byte) error { 54 | err := c.TypedVisitorConfig.UnmarshalJSON(b) 55 | if err != nil { 56 | return err 57 | } 58 | c.Mgr, err = unmarshalProxyMgr(b) 59 | return err 60 | } 61 | 62 | func unmarshalProxyMgr(b []byte) (c ProxyMgr, err error) { 63 | s := struct { 64 | Mgr ProxyMgr `json:"frpmgr"` 65 | }{} 66 | if err = json.Unmarshal(b, &s); err != nil { 67 | return 68 | } 69 | c = s.Mgr 70 | return 71 | } 72 | -------------------------------------------------------------------------------- /pkg/consts/config.go: -------------------------------------------------------------------------------- 1 | package consts 2 | 3 | const ( 4 | RangePrefix = "range:" 5 | DefaultSTUNServer = "stun.easyvoip.com:3478" 6 | DefaultServerPort = 7000 7 | ) 8 | 9 | // Protocols 10 | const ( 11 | ProtoTCP = "tcp" 12 | ProtoKCP = "kcp" 13 | ProtoQUIC = "quic" 14 | ProtoWebsocket = "websocket" 15 | ProtoWSS = "wss" 16 | ) 17 | 18 | var Protocols = []string{ProtoTCP, ProtoKCP, ProtoQUIC, ProtoWebsocket, ProtoWSS} 19 | 20 | // Proxy types 21 | const ( 22 | ProxyTypeTCP = "tcp" 23 | ProxyTypeUDP = "udp" 24 | ProxyTypeXTCP = "xtcp" 25 | ProxyTypeSTCP = "stcp" 26 | ProxyTypeSUDP = "sudp" 27 | ProxyTypeHTTP = "http" 28 | ProxyTypeHTTPS = "https" 29 | ProxyTypeTCPMUX = "tcpmux" 30 | ) 31 | 32 | var ProxyTypes = []string{ 33 | ProxyTypeTCP, ProxyTypeUDP, ProxyTypeXTCP, ProxyTypeSTCP, 34 | ProxyTypeSUDP, ProxyTypeHTTP, ProxyTypeHTTPS, ProxyTypeTCPMUX, 35 | } 36 | 37 | // Plugin types 38 | const ( 39 | PluginHttpProxy = "http_proxy" 40 | PluginSocks5 = "socks5" 41 | PluginStaticFile = "static_file" 42 | PluginHttps2Http = "https2http" 43 | PluginHttps2Https = "https2https" 44 | PluginHttp2Https = "http2https" 45 | PluginHttp2Http = "http2http" 46 | PluginUnixDomain = "unix_domain_socket" 47 | PluginTLS2Raw = "tls2raw" 48 | ) 49 | 50 | var PluginTypes = []string{ 51 | PluginHttp2Http, PluginHttp2Https, PluginHttps2Http, PluginHttps2Https, 52 | PluginHttpProxy, PluginSocks5, PluginStaticFile, PluginUnixDomain, PluginTLS2Raw, 53 | } 54 | 55 | // Auth methods 56 | const ( 57 | AuthToken = "token" 58 | AuthOIDC = "oidc" 59 | ) 60 | 61 | // Delete methods 62 | const ( 63 | DeleteAbsolute = "absolute" 64 | DeleteRelative = "relative" 65 | ) 66 | 67 | // TCP multiplexer 68 | const ( 69 | HTTPConnectTCPMultiplexer = "httpconnect" 70 | ) 71 | 72 | // Bandwidth 73 | var ( 74 | Bandwidth = []string{"MB", "KB"} 75 | BandwidthMode = []string{"client", "server"} 76 | ) 77 | 78 | // Log level 79 | const ( 80 | LogLevelTrace = "trace" 81 | LogLevelDebug = "debug" 82 | LogLevelInfo = "info" 83 | LogLevelWarn = "warn" 84 | LogLevelError = "error" 85 | ) 86 | 87 | var LogLevels = []string{LogLevelTrace, LogLevelDebug, LogLevelInfo, LogLevelWarn, LogLevelError} 88 | 89 | const DefaultLogMaxDays = 3 90 | -------------------------------------------------------------------------------- /pkg/consts/state.go: -------------------------------------------------------------------------------- 1 | package consts 2 | 3 | // ConfigState is the state of FRP daemon service 4 | type ConfigState int 5 | 6 | const ( 7 | ConfigStateUnknown ConfigState = iota 8 | ConfigStateStarted 9 | ConfigStateStopped 10 | ConfigStateStarting 11 | ConfigStateStopping 12 | ) 13 | 14 | // ProxyState is the state of a proxy. 15 | type ProxyState int 16 | 17 | const ( 18 | ProxyStateUnknown ProxyState = iota 19 | ProxyStateRunning 20 | ProxyStateError 21 | ) 22 | -------------------------------------------------------------------------------- /pkg/ipc/client.go: -------------------------------------------------------------------------------- 1 | package ipc 2 | 3 | import "context" 4 | 5 | // ProxyMessage is the status information of a proxy. 6 | type ProxyMessage struct { 7 | Name string 8 | Type string 9 | Status string 10 | Err string 11 | RemoteAddr string 12 | } 13 | 14 | // Client is used to query proxy state from the frp client. 15 | // It may be a pipe client or HTTP client. 16 | type Client interface { 17 | // SetCallback changes the callback function for the response message. 18 | SetCallback(cb func([]ProxyMessage)) 19 | // Run the client in blocking mode. 20 | Run(ctx context.Context) 21 | // Probe triggers a query request immediately. 22 | Probe(ctx context.Context) 23 | } 24 | -------------------------------------------------------------------------------- /pkg/ipc/pipe.go: -------------------------------------------------------------------------------- 1 | package ipc 2 | 3 | import ( 4 | "context" 5 | "encoding/gob" 6 | "time" 7 | 8 | "github.com/Microsoft/go-winio" 9 | ) 10 | 11 | type PipeClient struct { 12 | path string 13 | payload func() []string 14 | ch chan struct{} 15 | cb func([]ProxyMessage) 16 | } 17 | 18 | func NewPipeClient(name string, payload func() []string) *PipeClient { 19 | return &PipeClient{ 20 | path: `\\.\pipe\` + name, 21 | payload: payload, 22 | ch: make(chan struct{}, 1), 23 | } 24 | } 25 | 26 | func (p *PipeClient) SetCallback(cb func([]ProxyMessage)) { 27 | p.cb = cb 28 | } 29 | 30 | func (p *PipeClient) Run(ctx context.Context) { 31 | conn, err := winio.DialPipeContext(ctx, p.path) 32 | if err != nil { 33 | return 34 | } 35 | defer conn.Close() 36 | timer := time.NewTimer(0) 37 | defer timer.Stop() 38 | 39 | seq := []time.Duration{100 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second, 5 * time.Second} 40 | index := -1 41 | 42 | query := func() { 43 | if err = gob.NewEncoder(conn).Encode(p.payload()); err != nil { 44 | return 45 | } 46 | var msg []ProxyMessage 47 | if err = gob.NewDecoder(conn).Decode(&msg); err != nil { 48 | return 49 | } 50 | if p.cb != nil { 51 | p.cb(msg) 52 | } 53 | } 54 | for { 55 | select { 56 | case <-timer.C: 57 | query() 58 | if index < len(seq)-1 { 59 | index++ 60 | } 61 | timer.Reset(seq[index]) 62 | case <-p.ch: 63 | index = 0 64 | timer.Reset(seq[index]) 65 | case <-ctx.Done(): 66 | return 67 | } 68 | } 69 | } 70 | 71 | func (p *PipeClient) Probe(ctx context.Context) { 72 | select { 73 | case <-ctx.Done(): 74 | return 75 | case p.ch <- struct{}{}: 76 | default: 77 | return 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /pkg/ipc/server.go: -------------------------------------------------------------------------------- 1 | package ipc 2 | 3 | import ( 4 | "encoding/gob" 5 | "net" 6 | 7 | "github.com/Microsoft/go-winio" 8 | "github.com/fatedier/frp/client" 9 | ) 10 | 11 | type Server struct { 12 | listener net.Listener 13 | exporter client.StatusExporter 14 | } 15 | 16 | func NewServer(name string, exporter client.StatusExporter) (*Server, error) { 17 | listener, err := winio.ListenPipe(`\\.\pipe\`+name, &winio.PipeConfig{ 18 | MessageMode: true, 19 | InputBufferSize: 1024, 20 | OutputBufferSize: 2048, 21 | }) 22 | if err != nil { 23 | return nil, err 24 | } 25 | return &Server{listener, exporter}, nil 26 | } 27 | 28 | func (s *Server) Run() { 29 | for { 30 | conn, err := s.listener.Accept() 31 | if err != nil { 32 | return 33 | } 34 | go s.handle(conn) 35 | } 36 | } 37 | 38 | func (s *Server) handle(conn net.Conn) { 39 | defer conn.Close() 40 | for { 41 | var names []string 42 | if err := gob.NewDecoder(conn).Decode(&names); err != nil { 43 | return 44 | } 45 | msg := make([]ProxyMessage, 0, len(names)) 46 | for _, name := range names { 47 | if status, _ := s.exporter.GetProxyStatus(name); status != nil { 48 | msg = append(msg, ProxyMessage{ 49 | Name: status.Name, 50 | Type: status.Type, 51 | Status: status.Phase, 52 | Err: status.Err, 53 | RemoteAddr: status.RemoteAddr, 54 | }) 55 | } 56 | } 57 | if err := gob.NewEncoder(conn).Encode(msg); err != nil { 58 | return 59 | } 60 | } 61 | } 62 | 63 | func (s *Server) Close() error { 64 | return s.listener.Close() 65 | } 66 | -------------------------------------------------------------------------------- /pkg/layout/greedy.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import "github.com/lxn/walk" 4 | 5 | // GreedyLayoutItem is like walk.NewGreedyLayoutItem, but with specific orientation support. 6 | // If an orientation is provided, it will be greedy at the given orientation but not other orientations. 7 | type GreedyLayoutItem struct { 8 | *walk.LayoutItemBase 9 | item walk.LayoutItem 10 | orientation walk.LayoutFlags 11 | } 12 | 13 | // NewGreedyLayoutItem returns a layout item that is greedy at the given orientation. 14 | func NewGreedyLayoutItem(orientation walk.Orientation) walk.LayoutItem { 15 | layout := &GreedyLayoutItem{item: walk.NewGreedyLayoutItem()} 16 | layout.LayoutItemBase = layout.item.AsLayoutItemBase() 17 | switch orientation { 18 | case walk.Horizontal: 19 | layout.orientation = walk.GreedyVert 20 | case walk.Vertical: 21 | layout.orientation = walk.GreedyHorz 22 | case walk.NoOrientation: 23 | layout.orientation = walk.LayoutFlags(orientation) 24 | default: 25 | panic("invalid orientation") 26 | } 27 | return layout 28 | } 29 | 30 | func (hg *GreedyLayoutItem) LayoutFlags() walk.LayoutFlags { 31 | return hg.item.LayoutFlags() & ^hg.orientation 32 | } 33 | 34 | func (hg *GreedyLayoutItem) IdealSize() walk.Size { 35 | return hg.item.(walk.IdealSizer).IdealSize() 36 | } 37 | 38 | func (hg *GreedyLayoutItem) MinSize() walk.Size { 39 | return hg.item.(walk.MinSizer).MinSize() 40 | } 41 | -------------------------------------------------------------------------------- /pkg/layout/greedy_test.go: -------------------------------------------------------------------------------- 1 | package layout 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/lxn/walk" 7 | ) 8 | 9 | func TestNewGreedyLayoutItem(t *testing.T) { 10 | tests := []struct { 11 | input walk.Orientation 12 | expected, unexpected []walk.LayoutFlags 13 | }{ 14 | {input: walk.Horizontal, expected: []walk.LayoutFlags{walk.GreedyHorz}, unexpected: []walk.LayoutFlags{walk.GreedyVert}}, 15 | {input: walk.Vertical, expected: []walk.LayoutFlags{walk.GreedyVert}, unexpected: []walk.LayoutFlags{walk.GreedyHorz}}, 16 | {input: walk.NoOrientation, expected: []walk.LayoutFlags{walk.GreedyHorz, walk.GreedyVert}, unexpected: nil}, 17 | } 18 | for i, test := range tests { 19 | flags := NewGreedyLayoutItem(test.input).LayoutFlags() 20 | for _, f := range test.expected { 21 | if f&flags == 0 { 22 | t.Errorf("Test %d: expected: %v, got: %v", i, f, flags) 23 | } 24 | } 25 | for _, f := range test.unexpected { 26 | if f&flags > 0 { 27 | t.Errorf("Test %d: unexpected: %v, got: %v", i, f, flags) 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /pkg/res/res.go: -------------------------------------------------------------------------------- 1 | package res 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/lxn/walk" 8 | . "github.com/lxn/walk/declarative" 9 | "github.com/lxn/win" 10 | "github.com/samber/lo" 11 | "golang.org/x/sys/windows" 12 | 13 | "github.com/koho/frpmgr/i18n" 14 | "github.com/koho/frpmgr/pkg/validators" 15 | ) 16 | 17 | // Links 18 | const ( 19 | ProjectURL = "https://github.com/koho/frpmgr" 20 | FRPProjectURL = "https://github.com/fatedier/frp" 21 | UpdateURL = "https://api.github.com/repos/koho/frpmgr/releases/latest" 22 | ShareLinkScheme = "frp://" 23 | ) 24 | 25 | type Icon struct { 26 | Dll string 27 | Index int 28 | } 29 | 30 | // Icons 31 | var ( 32 | IconLogo = Icon{Index: 7} 33 | IconRandom = Icon{"imageres", -1024} 34 | IconSysCopy = Icon{"shell32", -243} 35 | IconNewConf = Icon{"shell32", -258} 36 | IconCreate = Icon{"shell32", -319} 37 | IconFileImport = Icon{"shell32", -241} 38 | IconURLImport = Icon{"imageres", -184} 39 | IconClipboard = Icon{"shell32", -16763} 40 | IconDelete = Icon{"shell32", -240} 41 | IconExport = Icon{"imageres", -174} 42 | IconQuickAdd = Icon{"shell32", -16769} 43 | IconEdit = Icon{"shell32", -16775} 44 | IconEnable = Icon{"shell32", -16810} 45 | IconDisable = Icon{"imageres", -1027} 46 | IconEditDialog = Icon{"imageres", -114} 47 | IconRemote = Icon{"imageres", -25} 48 | IconSSH = Icon{"imageres", -5372} 49 | IconVNC = Icon{"imageres", -110} 50 | IconWeb = Icon{"shell32", -14} 51 | IconFtp = Icon{"imageres", -143} 52 | IconHttpFile = Icon{"imageres", -73} 53 | IconHttpProxy = Icon{"imageres", -120} 54 | IconOpenPort = Icon{"shell32", -244} 55 | IconLock = Icon{"shell32", -48} 56 | IconFlatLock = Icon{"imageres", -1304} 57 | IconNewVersion1 = Icon{"imageres", -1028} 58 | IconNewVersion2 = Icon{"imageres", 1} 59 | IconUpdate = Icon{"shell32", -47} 60 | IconStateRunning = Icon{"imageres", -106} 61 | IconStateStopped = Icon{Index: 21} 62 | IconStateWorking = Icon{"shell32", -16739} 63 | IconDefaults = Icon{"imageres", -165} 64 | IconKey = Icon{"imageres", -5360} 65 | IconLanguage = Icon{"imageres", -94} 66 | IconNat = Icon{"imageres", -1043} 67 | IconFile = Icon{"shell32", -152} 68 | IconInfo = Icon{"imageres", -81} 69 | IconArrowUp = Icon{"shell32", -16817} 70 | IconMove = Icon{"imageres", -5313} 71 | IconSelectAll = Icon{"imageres", -5308} 72 | IconProxyRunning = Icon{"imageres", -1405} 73 | IconProxyError = Icon{"imageres", -1402} 74 | ) 75 | 76 | // Colors 77 | var ( 78 | ColorBlue = walk.RGB(0, 38, 247) 79 | ColorDarkBlue = walk.RGB(0, 51, 153) 80 | ColorLightBlue = walk.RGB(49, 94, 251) 81 | ColorGray = walk.RGB(109, 109, 109) 82 | ColorDarkGray = walk.RGB(85, 85, 85) 83 | ColorGrayBG = walk.Color(win.GetSysColor(win.COLOR_BTNFACE)) 84 | ) 85 | 86 | // Text 87 | var ( 88 | TextRegular Font 89 | TextMedium Font 90 | TextLarge Font 91 | ) 92 | 93 | func init() { 94 | var defaultFontFamily = "Microsoft YaHei UI" 95 | versionInfo := windows.RtlGetVersion() 96 | if versionInfo.MajorVersion == 10 && versionInfo.MinorVersion == 0 { 97 | if versionInfo.BuildNumber < 14393 { 98 | // Windows 10 / Windows 10 1511 99 | IconProxyRunning.Index = IconStateRunning.Index 100 | IconProxyError.Index = -98 101 | // Windows 10 102 | if versionInfo.BuildNumber == 10240 { 103 | IconFlatLock = IconLock 104 | } 105 | } else if versionInfo.BuildNumber == 14393 { 106 | // Windows Server 2016 / Windows 10 1607 107 | IconProxyRunning.Index = -1400 108 | IconProxyError.Index = -1405 109 | } else if versionInfo.BuildNumber == 15063 { 110 | // Windows 10 1703 111 | IconProxyRunning.Index = -1400 112 | IconProxyError.Index = -1402 113 | } 114 | } 115 | TextRegular = Font{Family: defaultFontFamily, PointSize: 9} 116 | TextMedium = Font{Family: defaultFontFamily, PointSize: 10} 117 | TextLarge = Font{Family: defaultFontFamily, PointSize: 12} 118 | } 119 | 120 | var ( 121 | SupportedConfigFormats = []string{".ini", ".toml", ".json", ".yml", ".yaml"} 122 | cfgPatterns = lo.Map(append([]string{".zip"}, SupportedConfigFormats...), func(item string, index int) string { 123 | return "*" + item 124 | }) 125 | ) 126 | 127 | // Filters 128 | var ( 129 | FilterAllFiles = i18n.Sprintf("All Files") + " (*.*)|*.*" 130 | FilterConfig = i18n.Sprintf("Configuration Files") + fmt.Sprintf(" (%s)|%s|", strings.Join(cfgPatterns, ", "), strings.Join(cfgPatterns, ";")) 131 | FilterZip = i18n.Sprintf("Configuration Files") + " (*.zip)|*.zip" 132 | FilterCert = i18n.Sprintf("Certificate Files") + " (*.crt, *.cer)|*.crt;*.cer|" 133 | FilterKey = i18n.Sprintf("Key Files") + " (*.key)|*.key|" 134 | ) 135 | 136 | // Validators 137 | var ( 138 | ValidateNonEmpty = validators.Regexp{Pattern: "[^\\s]+"} 139 | ) 140 | 141 | // Dialogs 142 | const ( 143 | DialogValidate = "VALDLG" 144 | DialogTitle = 2000 145 | DialogStatic1 = 2001 146 | DialogStatic2 = 2002 147 | DialogEdit = 2003 148 | DialogIcon = 2004 149 | ) 150 | -------------------------------------------------------------------------------- /pkg/sec/passwd.go: -------------------------------------------------------------------------------- 1 | package sec 2 | 3 | import ( 4 | "crypto/sha1" 5 | "encoding/base64" 6 | ) 7 | 8 | // EncryptPassword returns a Base64-encoded string of the hashed password. 9 | func EncryptPassword(password string) string { 10 | hashed := sha1.Sum([]byte(password)) 11 | return base64.StdEncoding.EncodeToString(hashed[:]) 12 | } 13 | -------------------------------------------------------------------------------- /pkg/sec/passwd_test.go: -------------------------------------------------------------------------------- 1 | package sec 2 | 3 | import "testing" 4 | 5 | func TestEncryptPassword(t *testing.T) { 6 | output := EncryptPassword("123456") 7 | expected := "fEqNCco3Yq9h5ZUglD3CZJT4lBs=" 8 | if output != expected { 9 | t.Errorf("Expected: %v, got: %v", expected, output) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /pkg/util/file.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "archive/zip" 5 | "bufio" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "regexp" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | // SplitExt splits the path into base name and file extension 15 | func SplitExt(path string) (string, string) { 16 | if path == "" { 17 | return "", "" 18 | } 19 | fileName := filepath.Base(path) 20 | ext := filepath.Ext(path) 21 | return strings.TrimSuffix(fileName, ext), ext 22 | } 23 | 24 | // FileExists checks whether the given path is a file 25 | func FileExists(path string) bool { 26 | info, err := os.Stat(path) 27 | if err != nil { 28 | return false 29 | } 30 | return !info.IsDir() 31 | } 32 | 33 | // FindLogFiles returns the files and dates archived by date 34 | func FindLogFiles(path string) ([]string, []time.Time, error) { 35 | if path == "" || path == "console" { 36 | return nil, nil, os.ErrInvalid 37 | } 38 | fileDir, fileName := filepath.Split(path) 39 | baseName, ext := SplitExt(fileName) 40 | pattern := regexp.MustCompile(`^\.\d{4}(0[1-9]|1[0-2])(0[1-9]|[12][0-9]|3[01])-([0-1][0-9]|2[0-3])([0-5][0-9])([0-5][0-9])$`) 41 | if fileDir == "" { 42 | fileDir = "." 43 | } 44 | files, err := os.ReadDir(fileDir) 45 | if err != nil { 46 | return nil, nil, err 47 | } 48 | logs := []string{filepath.Clean(path)} 49 | dates := []time.Time{{}} 50 | for _, file := range files { 51 | if strings.HasPrefix(file.Name(), baseName) && strings.HasSuffix(file.Name(), ext) { 52 | tailPart := strings.TrimPrefix(file.Name(), baseName) 53 | datePart := strings.TrimSuffix(tailPart, ext) 54 | if pattern.MatchString(datePart) { 55 | if date, err := time.ParseInLocation("20060102-150405", datePart[1:], time.Local); err == nil { 56 | logs = append(logs, filepath.Join(fileDir, file.Name())) 57 | dates = append(dates, date) 58 | } 59 | } 60 | } 61 | } 62 | return logs, dates, nil 63 | } 64 | 65 | // DeleteFiles removes the given file list ignoring errors 66 | func DeleteFiles(files []string) { 67 | for _, file := range files { 68 | os.Remove(file) 69 | } 70 | } 71 | 72 | // ReadFileLines reads the last n lines in a file starting at a given offset 73 | func ReadFileLines(path string, offset int64, n int) ([]string, int, int64, error) { 74 | file, err := os.Open(path) 75 | if err != nil { 76 | return nil, -1, 0, err 77 | } 78 | defer file.Close() 79 | _, err = file.Seek(offset, io.SeekStart) 80 | if err != nil { 81 | return nil, -1, 0, err 82 | } 83 | reader := bufio.NewReader(file) 84 | 85 | var line string 86 | lines := make([]string, 0) 87 | i := -1 88 | for { 89 | line, err = reader.ReadString('\n') 90 | if err != nil { 91 | break 92 | } 93 | if n < 0 || len(lines) < n { 94 | lines = append(lines, line) 95 | } else { 96 | i = (i + 1) % n 97 | lines[i] = line 98 | } 99 | } 100 | offset, err = file.Seek(0, io.SeekCurrent) 101 | if err != nil { 102 | return nil, -1, 0, err 103 | } 104 | if i >= 0 { 105 | i = (i + 1) % n 106 | } 107 | return lines, i, offset, nil 108 | } 109 | 110 | // ZipFiles compresses the given file list to a zip file 111 | func ZipFiles(filename string, files map[string]string) error { 112 | newZipFile, err := os.Create(filename) 113 | if err != nil { 114 | return err 115 | } 116 | defer newZipFile.Close() 117 | 118 | zipWriter := zip.NewWriter(newZipFile) 119 | defer zipWriter.Close() 120 | 121 | // Add files to zip 122 | for src, dst := range files { 123 | if err = addFileToZip(zipWriter, src, dst); err != nil { 124 | return err 125 | } 126 | } 127 | return nil 128 | } 129 | 130 | func addFileToZip(zipWriter *zip.Writer, src, dst string) error { 131 | fileToZip, err := os.Open(src) 132 | if err != nil { 133 | return err 134 | } 135 | defer fileToZip.Close() 136 | 137 | info, err := fileToZip.Stat() 138 | if err != nil { 139 | return err 140 | } 141 | 142 | header, err := zip.FileInfoHeader(info) 143 | if err != nil { 144 | return err 145 | } 146 | header.Name = filepath.Base(dst) 147 | 148 | // Change to deflate to gain better compression 149 | header.Method = zip.Deflate 150 | 151 | writer, err := zipWriter.CreateHeader(header) 152 | if err != nil { 153 | return err 154 | } 155 | _, err = io.Copy(writer, fileToZip) 156 | return err 157 | } 158 | 159 | // IsDirectory determines if a file represented by `path` is a directory or not 160 | func IsDirectory(path string) (bool, error) { 161 | fileInfo, err := os.Stat(path) 162 | if err != nil { 163 | return false, err 164 | } 165 | return fileInfo.IsDir(), err 166 | } 167 | 168 | // FileNameWithoutExt returns the last element of path without the file extension. 169 | func FileNameWithoutExt(path string) string { 170 | if path == "" { 171 | return "" 172 | } 173 | return strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)) 174 | } 175 | -------------------------------------------------------------------------------- /pkg/util/file_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "os" 5 | "reflect" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestSplitExt(t *testing.T) { 11 | tests := []struct { 12 | input string 13 | expectedName string 14 | expectedExt string 15 | }{ 16 | {input: "C:\\test\\a.ini", expectedName: "a", expectedExt: ".ini"}, 17 | {input: "b.exe", expectedName: "b", expectedExt: ".exe"}, 18 | {input: "c", expectedName: "c", expectedExt: ""}, 19 | {input: "", expectedName: "", expectedExt: ""}, 20 | } 21 | for i, test := range tests { 22 | name, ext := SplitExt(test.input) 23 | if name != test.expectedName { 24 | t.Errorf("Test %d: expected: %v, got: %v", i, test.expectedName, name) 25 | } 26 | if ext != test.expectedExt { 27 | t.Errorf("Test %d: expected: %v, got: %v", i, test.expectedExt, ext) 28 | } 29 | } 30 | } 31 | 32 | func TestFindLogFiles(t *testing.T) { 33 | tests := []struct { 34 | create []string 35 | expectedFiles []string 36 | expectedDates []time.Time 37 | }{ 38 | { 39 | create: []string{"example.log", "example.20230320-000000.log", "example.20230321-010203.log", "example.2023-03-21.log"}, 40 | expectedFiles: []string{"example.log", "example.20230320-000000.log", "example.20230321-010203.log"}, 41 | expectedDates: []time.Time{ 42 | {}, 43 | time.Date(2023, 3, 20, 0, 0, 0, 0, time.Local), 44 | time.Date(2023, 3, 21, 1, 2, 3, 0, time.Local), 45 | }, 46 | }, 47 | } 48 | if err := os.MkdirAll("testdata", 0750); err != nil { 49 | t.Fatal(err) 50 | } 51 | os.Chdir("testdata") 52 | for i, test := range tests { 53 | for _, f := range test.create { 54 | os.WriteFile(f, []byte("test"), 0666) 55 | } 56 | logs, dates, err := FindLogFiles(test.create[0]) 57 | if err != nil { 58 | t.Error(err) 59 | continue 60 | } 61 | if !reflect.DeepEqual(logs, test.expectedFiles) { 62 | t.Errorf("Test %d: expected: %v, got: %v", i, test.expectedFiles, logs) 63 | } 64 | if !reflect.DeepEqual(dates, test.expectedDates) { 65 | t.Errorf("Test %d: expected: %v, got: %v", i, test.expectedDates, dates) 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /pkg/util/misc.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | ) 7 | 8 | // PruneByTag returns a copy of "in" that only contains fields with the given tag and value 9 | func PruneByTag(in interface{}, value string, tag string) (interface{}, error) { 10 | inValue := reflect.ValueOf(in) 11 | 12 | ret := reflect.New(inValue.Type()).Elem() 13 | 14 | if err := prune(inValue, ret, value, tag); err != nil { 15 | return nil, err 16 | } 17 | return ret.Interface(), nil 18 | } 19 | 20 | func prune(inValue reflect.Value, ret reflect.Value, value string, tag string) error { 21 | switch inValue.Kind() { 22 | case reflect.Ptr: 23 | if inValue.IsNil() { 24 | return nil 25 | } 26 | if ret.IsNil() { 27 | // init ret and go to next level 28 | ret.Set(reflect.New(inValue.Type().Elem())) 29 | } 30 | return prune(inValue.Elem(), ret.Elem(), value, tag) 31 | case reflect.Struct: 32 | var fValue reflect.Value 33 | var fRet reflect.Value 34 | // search tag that has key equal to value 35 | for i := 0; i < inValue.NumField(); i++ { 36 | f := inValue.Type().Field(i) 37 | if key, ok := f.Tag.Lookup(tag); ok { 38 | if key == "*" || key == value { 39 | fValue = inValue.Field(i) 40 | fRet = ret.Field(i) 41 | fRet.Set(fValue) 42 | } 43 | } 44 | } 45 | } 46 | return nil 47 | } 48 | 49 | func GetMapWithoutPrefix(set map[string]string, prefix string) map[string]string { 50 | m := make(map[string]string) 51 | 52 | for key, value := range set { 53 | if strings.HasPrefix(key, prefix) { 54 | m[strings.TrimPrefix(key, prefix)] = value 55 | } 56 | } 57 | 58 | if len(m) == 0 { 59 | return nil 60 | } 61 | 62 | return m 63 | } 64 | 65 | // MoveSlice moves the element s[i] to index j in s. 66 | func MoveSlice[S ~[]E, E any](s S, i, j int) { 67 | x := s[i] 68 | if i < j { 69 | copy(s[i:j], s[i+1:j+1]) 70 | } else if i > j { 71 | copy(s[j+1:i+1], s[j:i]) 72 | } 73 | s[j] = x 74 | } 75 | -------------------------------------------------------------------------------- /pkg/util/misc_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "testing" 4 | 5 | type tagTest struct { 6 | Tag string 7 | Name string `t1:"true" t2:"true"` 8 | Age int `t2:"true"` 9 | } 10 | 11 | func TestPruneByTag(t *testing.T) { 12 | tests := []struct { 13 | input tagTest 14 | expected tagTest 15 | }{ 16 | {input: tagTest{Tag: "t1", Name: "John", Age: 34}, expected: tagTest{Name: "John"}}, 17 | {input: tagTest{Tag: "t2", Name: "Ben", Age: 20}, expected: tagTest{Name: "Ben", Age: 20}}, 18 | {input: tagTest{Name: "Mary", Age: 50}, expected: tagTest{}}, 19 | } 20 | for i, test := range tests { 21 | output, err := PruneByTag(test.input, "true", test.input.Tag) 22 | if err != nil { 23 | t.Fatalf("Test %d: expected no error but found one for input %v, got: %v", i, test.input, err) 24 | } 25 | if output != test.expected { 26 | t.Errorf("Test %d: expected: %v, got: %v", i, test.expected, output) 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /pkg/util/net.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "mime" 8 | "net/http" 9 | "path" 10 | "time" 11 | ) 12 | 13 | // DownloadFile downloads a file from the given url 14 | func DownloadFile(ctx context.Context, url string) (filename, mediaType string, data []byte, err error) { 15 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil) 16 | if err != nil { 17 | return 18 | } 19 | client := http.Client{Timeout: 10 * time.Second} 20 | resp, err := client.Do(req) 21 | if err != nil { 22 | return 23 | } 24 | defer resp.Body.Close() 25 | 26 | // Check server response 27 | if resp.StatusCode != http.StatusOK { 28 | err = fmt.Errorf("bad status: %s", resp.Status) 29 | return 30 | } 31 | // Use the filename in header 32 | if cd := resp.Header.Get("Content-Disposition"); cd != "" { 33 | if _, params, err := mime.ParseMediaType(cd); err == nil { 34 | filename = params["filename"] 35 | } 36 | } 37 | // Use the base filename part of the URL 38 | if filename == "" { 39 | filename = path.Base(resp.Request.URL.Path) 40 | } 41 | if mediaType, _, err = mime.ParseMediaType(resp.Header.Get("Content-Type")); err == nil { 42 | data, err = io.ReadAll(resp.Body) 43 | return filename, mediaType, data, err 44 | } else { 45 | return "", "", nil, err 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pkg/util/strings.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "strings" 7 | "unicode/utf8" 8 | ) 9 | 10 | // GetOrElse returns the given string if it's non-empty, or returns the default string. 11 | func GetOrElse(s string, def string) string { 12 | if strings.TrimSpace(s) != "" { 13 | return s 14 | } 15 | return def 16 | } 17 | 18 | // RuneSizeInString returns a slice of each character's size in the given string 19 | func RuneSizeInString(s string) []int { 20 | sizes := make([]int, 0) 21 | for len(s) > 0 { 22 | _, size := utf8.DecodeRuneInString(s) 23 | sizes = append(sizes, size) 24 | s = s[size:] 25 | } 26 | return sizes 27 | } 28 | 29 | // RandToken generates a random hex value. 30 | func RandToken(n int) (string, error) { 31 | bytes := make([]byte, n) 32 | if _, err := rand.Read(bytes); err != nil { 33 | return "", err 34 | } 35 | return hex.EncodeToString(bytes), nil 36 | } 37 | -------------------------------------------------------------------------------- /pkg/util/strings_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestGetOrElse(t *testing.T) { 9 | tests := []struct { 10 | input string 11 | def string 12 | expected string 13 | }{ 14 | {input: "abc", def: "def", expected: "abc"}, 15 | {input: "", def: "def", expected: "def"}, 16 | {input: " ", def: "def", expected: "def"}, 17 | } 18 | for i, test := range tests { 19 | output := GetOrElse(test.input, test.def) 20 | if output != test.expected { 21 | t.Errorf("Test %d: expected: %v, got: %v", i, test.expected, output) 22 | } 23 | } 24 | } 25 | 26 | func TestRuneSizeInString(t *testing.T) { 27 | str := "Hello, 世界" 28 | expected := []int{1, 1, 1, 1, 1, 1, 1, 3, 3} 29 | output := RuneSizeInString(str) 30 | if !reflect.DeepEqual(output, expected) { 31 | t.Errorf("Expected: %v, got: %v", expected, output) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /pkg/validators/passwd.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "github.com/lxn/walk" 5 | 6 | "github.com/koho/frpmgr/i18n" 7 | ) 8 | 9 | type PasswordValidator struct { 10 | Password **walk.LineEdit 11 | } 12 | 13 | func (p *PasswordValidator) Validate(v interface{}) error { 14 | text := v.(string) 15 | if text == "" { 16 | return silentErr 17 | } 18 | if (*p.Password).Text() == text { 19 | return nil 20 | } 21 | return walk.NewValidationError(i18n.Sprintf("Password mismatch"), i18n.Sprintf("Please check and try again.")) 22 | } 23 | 24 | // ConfirmPassword checks whether the input text is equal to the password field. 25 | type ConfirmPassword struct { 26 | Password **walk.LineEdit 27 | } 28 | 29 | func (c ConfirmPassword) Create() (walk.Validator, error) { 30 | return &PasswordValidator{c.Password}, nil 31 | } 32 | -------------------------------------------------------------------------------- /pkg/validators/presenter.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/lxn/walk" 7 | ) 8 | 9 | var silentErr = errors.New("") 10 | 11 | type ToolTipErrorPresenter struct { 12 | *walk.ToolTipErrorPresenter 13 | } 14 | 15 | func NewToolTipErrorPresenter() (*ToolTipErrorPresenter, error) { 16 | p, err := walk.NewToolTipErrorPresenter() 17 | if err != nil { 18 | return nil, err 19 | } 20 | return &ToolTipErrorPresenter{p}, nil 21 | } 22 | 23 | func (ttep *ToolTipErrorPresenter) PresentError(err error, widget walk.Widget) { 24 | if errors.Is(err, silentErr) { 25 | ttep.ToolTipErrorPresenter.PresentError(nil, widget) 26 | } else { 27 | ttep.ToolTipErrorPresenter.PresentError(err, widget) 28 | } 29 | } 30 | 31 | // SilentToolTipErrorPresenter hides the tooltip when the input value is empty. 32 | type SilentToolTipErrorPresenter struct { 33 | } 34 | 35 | func (SilentToolTipErrorPresenter) Create() (walk.ErrorPresenter, error) { 36 | return NewToolTipErrorPresenter() 37 | } 38 | -------------------------------------------------------------------------------- /pkg/validators/regexp.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "github.com/lxn/walk" 5 | ) 6 | 7 | type RegexpValidator struct { 8 | *walk.RegexpValidator 9 | } 10 | 11 | func NewRegexpValidator(pattern string) (*RegexpValidator, error) { 12 | re, err := walk.NewRegexpValidator(pattern) 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | return &RegexpValidator{re}, nil 18 | } 19 | 20 | func (rv *RegexpValidator) Validate(v interface{}) error { 21 | err := rv.RegexpValidator.Validate(v) 22 | if str, ok := v.(string); ok && str == "" && err != nil { 23 | return silentErr 24 | } 25 | return err 26 | } 27 | 28 | type Regexp struct { 29 | Pattern string 30 | } 31 | 32 | func (re Regexp) Create() (walk.Validator, error) { 33 | return NewRegexpValidator(re.Pattern) 34 | } 35 | -------------------------------------------------------------------------------- /pkg/validators/regexp_test.go: -------------------------------------------------------------------------------- 1 | package validators 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | func TestRegexp(t *testing.T) { 9 | r, err := Regexp{Pattern: "^\\d+$"}.Create() 10 | if err != nil { 11 | t.Fatal(err) 12 | } 13 | if err = r.Validate(""); !errors.Is(err, silentErr) { 14 | t.Errorf("Expected: %v, got: %v", silentErr, err) 15 | } 16 | tests := []struct { 17 | input string 18 | shouldErr bool 19 | }{ 20 | {input: "123", shouldErr: false}, 21 | {input: "a1", shouldErr: true}, 22 | {input: "1.1", shouldErr: true}, 23 | {input: " 1", shouldErr: true}, 24 | {input: "1a", shouldErr: true}, 25 | } 26 | for i, test := range tests { 27 | err = r.Validate(test.input) 28 | if (test.shouldErr && err == nil) || (!test.shouldErr && err != nil) { 29 | t.Errorf("Test %d: expected: %v, got: %v", i, test.shouldErr, err) 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "github.com/fatedier/frp/pkg/util/version" 5 | ) 6 | 7 | var ( 8 | Number = "1.21.1" 9 | // FRPVersion is the version of FRP used by this program 10 | FRPVersion = version.Full() 11 | // BuildDate is the day that this program was built 12 | BuildDate = "" 13 | ) 14 | -------------------------------------------------------------------------------- /resource.go: -------------------------------------------------------------------------------- 1 | //go:build ignore 2 | 3 | // generates resource files. 4 | 5 | package main 6 | 7 | import ( 8 | "fmt" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "strings" 13 | "time" 14 | 15 | "github.com/koho/frpmgr/pkg/version" 16 | ) 17 | 18 | var ( 19 | versionArray = strings.ReplaceAll(version.Number, ".", ",") 20 | archMap = map[string]string{"amd64": "pe-x86-64", "386": "pe-i386"} 21 | ) 22 | 23 | func main() { 24 | rcFiles, err := filepath.Glob("cmd/*/*.rc") 25 | if err != nil { 26 | println(err.Error()) 27 | os.Exit(1) 28 | } 29 | for _, rc := range rcFiles { 30 | for goArch, resArch := range archMap { 31 | output := strings.TrimSuffix(rc, filepath.Ext(rc)) + fmt.Sprintf("_windows_%s.syso", goArch) 32 | res, err := exec.Command("windres", "-DVERSION_ARRAY="+versionArray, "-DVERSION_STR="+version.Number, 33 | "-i", rc, "-o", output, "-O", "coff", "-c", "65001", "-F", resArch).CombinedOutput() 34 | if err != nil { 35 | println(err.Error(), string(res)) 36 | os.Exit(1) 37 | } 38 | } 39 | } 40 | fmt.Println("VERSION=" + version.Number) 41 | fmt.Println("BUILD_DATE=" + time.Now().Format(time.DateOnly)) 42 | } 43 | -------------------------------------------------------------------------------- /services/client.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | _ "github.com/fatedier/frp/assets/frpc" 8 | "github.com/fatedier/frp/client" 9 | "github.com/fatedier/frp/client/proxy" 10 | "github.com/fatedier/frp/pkg/config" 11 | "github.com/fatedier/frp/pkg/config/v1" 12 | "github.com/fatedier/frp/pkg/util/log" 13 | 14 | "github.com/koho/frpmgr/pkg/consts" 15 | ) 16 | 17 | type FrpClientService struct { 18 | svr *client.Service 19 | file string 20 | cfg *v1.ClientCommonConfig 21 | done chan struct{} 22 | statusExporter client.StatusExporter 23 | } 24 | 25 | func NewFrpClientService(cfgFile string) (*FrpClientService, error) { 26 | cfg, pxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, false) 27 | if err != nil { 28 | return nil, err 29 | } 30 | svr, err := client.NewService(client.ServiceOptions{ 31 | Common: cfg, 32 | ProxyCfgs: pxyCfgs, 33 | VisitorCfgs: visitorCfgs, 34 | ConfigFilePath: cfgFile, 35 | }) 36 | if err != nil { 37 | return nil, err 38 | } 39 | log.InitLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays), cfg.Log.DisablePrintColor) 40 | return &FrpClientService{ 41 | svr: svr, 42 | file: cfgFile, 43 | cfg: cfg, 44 | done: make(chan struct{}), 45 | statusExporter: svr.StatusExporter(), 46 | }, nil 47 | } 48 | 49 | // Run starts frp client service in blocking mode. 50 | func (s *FrpClientService) Run() { 51 | defer close(s.done) 52 | if s.file != "" { 53 | log.Infof("start frpc service for config file [%s]", s.file) 54 | defer log.Infof("frpc service for config file [%s] stopped", s.file) 55 | } 56 | 57 | // There's no guarantee that this function will return after a close call. 58 | // So we can't wait for the Run function to finish. 59 | if err := s.svr.Run(context.Background()); err != nil { 60 | log.Errorf("run service error: %v", err) 61 | } 62 | } 63 | 64 | // Stop closes all frp connections. 65 | func (s *FrpClientService) Stop(wait bool) { 66 | // Close client service. 67 | if wait { 68 | s.svr.GracefulClose(500 * time.Millisecond) 69 | } else { 70 | s.svr.Close() 71 | } 72 | } 73 | 74 | // Reload creates or updates or removes proxies of frpc. 75 | func (s *FrpClientService) Reload() error { 76 | _, pxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(s.file, false) 77 | if err != nil { 78 | return err 79 | } 80 | return s.svr.UpdateAllConfigurer(pxyCfgs, visitorCfgs) 81 | } 82 | 83 | func (s *FrpClientService) Done() <-chan struct{} { 84 | return s.done 85 | } 86 | 87 | func (s *FrpClientService) GetProxyStatus(name string) (status *proxy.WorkingStatus, ok bool) { 88 | proxyName := name 89 | if s.cfg.User != "" { 90 | proxyName = s.cfg.User + "." + name 91 | } 92 | status, ok = s.statusExporter.GetProxyStatus(proxyName) 93 | if ok { 94 | status.Name = name 95 | if status.Err == "" { 96 | if status.Type == consts.ProxyTypeTCP || status.Type == consts.ProxyTypeUDP { 97 | status.RemoteAddr = s.cfg.ServerAddr + status.RemoteAddr 98 | } 99 | } else { 100 | status.RemoteAddr = "" 101 | } 102 | } 103 | return 104 | } 105 | -------------------------------------------------------------------------------- /services/frp.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "os" 5 | "reflect" 6 | "unsafe" 7 | 8 | frpconfig "github.com/fatedier/frp/pkg/config" 9 | "github.com/fatedier/frp/pkg/config/v1/validation" 10 | "github.com/fatedier/frp/pkg/util/log" 11 | glog "github.com/fatedier/golib/log" 12 | 13 | "github.com/koho/frpmgr/pkg/config" 14 | "github.com/koho/frpmgr/pkg/util" 15 | ) 16 | 17 | func deleteFrpConfig(serviceName string, configPath string, c config.Config) { 18 | // Delete logs 19 | logWriter := reflect.ValueOf(log.Logger).Elem().FieldByName("out") 20 | if writer, ok := reflect.NewAt(logWriter.Type(), unsafe.Pointer(logWriter.UnsafeAddr())).Elem().Interface().(*glog.RotateFileWriter); ok { 21 | writer.Close() 22 | } 23 | if logs, _, err := util.FindLogFiles(c.GetLogFile()); err == nil { 24 | util.DeleteFiles(logs) 25 | } 26 | // Delete config file 27 | os.Remove(configPath) 28 | // Delete service 29 | m, err := serviceManager() 30 | if err != nil { 31 | return 32 | } 33 | defer m.Disconnect() 34 | service, err := m.OpenService(serviceName) 35 | if err != nil { 36 | return 37 | } 38 | defer service.Close() 39 | service.Delete() 40 | } 41 | 42 | // VerifyClientConfig validates the frp client config file 43 | func VerifyClientConfig(path string) error { 44 | cfg, proxyCfgs, visitorCfgs, _, err := frpconfig.LoadClientConfig(path, false) 45 | if err != nil { 46 | return err 47 | } 48 | _, err = validation.ValidateAllClientConfig(cfg, proxyCfgs, visitorCfgs) 49 | return err 50 | } 51 | -------------------------------------------------------------------------------- /services/install.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "time" 7 | 8 | "golang.org/x/sys/windows" 9 | "golang.org/x/sys/windows/svc" 10 | "golang.org/x/sys/windows/svc/mgr" 11 | ) 12 | 13 | var cachedServiceManager *mgr.Mgr 14 | 15 | func serviceManager() (*mgr.Mgr, error) { 16 | if cachedServiceManager != nil { 17 | return cachedServiceManager, nil 18 | } 19 | m, err := mgr.Connect() 20 | if err != nil { 21 | return nil, err 22 | } 23 | cachedServiceManager = m 24 | return cachedServiceManager, nil 25 | } 26 | 27 | // InstallService runs the program as Windows service 28 | func InstallService(name string, configPath string, manual bool) error { 29 | m, err := serviceManager() 30 | if err != nil { 31 | return err 32 | } 33 | path, err := os.Executable() 34 | if err != nil { 35 | return err 36 | } 37 | if configPath, err = filepath.Abs(configPath); err != nil { 38 | return err 39 | } 40 | serviceName := ServiceNameOfClient(configPath) 41 | service, err := m.OpenService(serviceName) 42 | if err == nil { 43 | _, err = service.Query() 44 | if err != nil && err != windows.ERROR_SERVICE_MARKED_FOR_DELETE { 45 | service.Close() 46 | return err 47 | } 48 | err = service.Delete() 49 | service.Close() 50 | if err != nil && err != windows.ERROR_SERVICE_MARKED_FOR_DELETE { 51 | return err 52 | } 53 | for i := 0; i < 2; i++ { 54 | service, err = m.OpenService(serviceName) 55 | if err != nil && err != windows.ERROR_SERVICE_MARKED_FOR_DELETE { 56 | break 57 | } 58 | if service != nil { 59 | service.Close() 60 | } 61 | time.Sleep(time.Second / 3) 62 | } 63 | } 64 | 65 | conf := mgr.Config{ 66 | ServiceType: windows.SERVICE_WIN32_OWN_PROCESS, 67 | StartType: mgr.StartAutomatic, 68 | ErrorControl: mgr.ErrorNormal, 69 | DisplayName: DisplayNameOfClient(name), 70 | Description: "FRP Runtime Service for FRP Manager.", 71 | SidType: windows.SERVICE_SID_TYPE_UNRESTRICTED, 72 | } 73 | if manual { 74 | conf.StartType = mgr.StartManual 75 | } 76 | service, err = m.CreateService(serviceName, path, conf, "-c", configPath) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | err = service.Start() 82 | service.Close() 83 | return err 84 | } 85 | 86 | // UninstallService stops and removes the given service 87 | func UninstallService(configPath string, wait bool) error { 88 | m, err := serviceManager() 89 | if err != nil { 90 | return err 91 | } 92 | serviceName := ServiceNameOfClient(configPath) 93 | service, err := m.OpenService(serviceName) 94 | if err != nil { 95 | return err 96 | } 97 | service.Control(svc.Stop) 98 | if wait { 99 | try := 0 100 | for { 101 | time.Sleep(time.Second / 3) 102 | try++ 103 | status, err := service.Query() 104 | if err != nil { 105 | return err 106 | } 107 | if status.ProcessId == 0 || try >= 3 { 108 | break 109 | } 110 | } 111 | } 112 | err = service.Delete() 113 | err2 := service.Close() 114 | if err != nil && err != windows.ERROR_SERVICE_MARKED_FOR_DELETE { 115 | return err 116 | } 117 | return err2 118 | } 119 | -------------------------------------------------------------------------------- /services/service.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "crypto/md5" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/fatedier/frp/pkg/util/log" 11 | "golang.org/x/sys/windows/svc" 12 | 13 | "github.com/koho/frpmgr/pkg/config" 14 | "github.com/koho/frpmgr/pkg/ipc" 15 | "github.com/koho/frpmgr/pkg/util" 16 | ) 17 | 18 | func ServiceNameOfClient(configPath string) string { 19 | return fmt.Sprintf("frpmgr_%x", md5.Sum([]byte(util.FileNameWithoutExt(configPath)))) 20 | } 21 | 22 | func DisplayNameOfClient(name string) string { 23 | return "FRP Manager: " + name 24 | } 25 | 26 | type frpService struct { 27 | configPath string 28 | } 29 | 30 | func (service *frpService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (svcSpecificEC bool, exitCode uint32) { 31 | path, err := os.Executable() 32 | if err != nil { 33 | return 34 | } 35 | if err = os.Chdir(filepath.Dir(path)); err != nil { 36 | return 37 | } 38 | changes <- svc.Status{State: svc.StartPending} 39 | 40 | defer func() { 41 | changes <- svc.Status{State: svc.StopPending} 42 | }() 43 | 44 | cc, err := config.UnmarshalClientConf(service.configPath) 45 | if err != nil { 46 | return 47 | } 48 | var expired <-chan time.Time 49 | t, err := config.Expiry(service.configPath, cc.AutoDelete) 50 | switch err { 51 | case nil: 52 | if t <= 0 { 53 | deleteFrpConfig(args[0], service.configPath, cc) 54 | return 55 | } 56 | expired = time.After(t) 57 | case os.ErrNoDeadline: 58 | break 59 | default: 60 | return 61 | } 62 | 63 | svr, err := NewFrpClientService(service.configPath) 64 | if err != nil { 65 | return 66 | } 67 | 68 | is, err := ipc.NewServer(args[0], svr) 69 | if err != nil { 70 | return 71 | } 72 | defer is.Close() 73 | 74 | go svr.Run() 75 | go is.Run() 76 | 77 | changes <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown | svc.AcceptParamChange} 78 | 79 | for { 80 | select { 81 | case c := <-r: 82 | switch c.Cmd { 83 | case svc.Stop, svc.Shutdown: 84 | svr.Stop(false) 85 | return 86 | case svc.ParamChange: 87 | // Reload service 88 | if err = svr.Reload(); err != nil { 89 | log.Errorf("reload frp config error: %v", err) 90 | } 91 | case svc.Interrogate: 92 | changes <- c.CurrentStatus 93 | default: 94 | } 95 | case <-svr.Done(): 96 | return 97 | case <-expired: 98 | svr.Stop(false) 99 | deleteFrpConfig(args[0], service.configPath, cc) 100 | return 101 | } 102 | } 103 | } 104 | 105 | // Run executes frp service in background service process. 106 | func Run(configPath string) error { 107 | serviceName := ServiceNameOfClient(configPath) 108 | return svc.Run(serviceName, &frpService{configPath}) 109 | } 110 | 111 | // ReloadService sends a reload event to the frp service 112 | // which triggers hot-reloading of frp configuration. 113 | func ReloadService(configPath string) error { 114 | m, err := serviceManager() 115 | if err != nil { 116 | return err 117 | } 118 | 119 | svcName := ServiceNameOfClient(configPath) 120 | service, err := m.OpenService(svcName) 121 | if err != nil { 122 | return err 123 | } 124 | defer service.Close() 125 | _, err = service.Control(svc.ParamChange) 126 | return err 127 | } 128 | -------------------------------------------------------------------------------- /services/tracker.go: -------------------------------------------------------------------------------- 1 | package services 2 | 3 | import ( 4 | "sync" 5 | "sync/atomic" 6 | 7 | "golang.org/x/sys/windows" 8 | "golang.org/x/sys/windows/svc" 9 | "golang.org/x/sys/windows/svc/mgr" 10 | 11 | "github.com/koho/frpmgr/pkg/consts" 12 | ) 13 | 14 | type ConfigStateCallback func(path string, state consts.ConfigState) 15 | 16 | type tracker struct { 17 | service *mgr.Service 18 | done sync.WaitGroup 19 | once atomic.Uint32 20 | } 21 | 22 | var ( 23 | trackedConfigs = make(map[string]*tracker) 24 | trackedConfigsLock = sync.Mutex{} 25 | ) 26 | 27 | func trackExistingConfigs(paths func() []string, cb ConfigStateCallback) error { 28 | m, err := serviceManager() 29 | if err != nil { 30 | return err 31 | } 32 | for _, path := range paths() { 33 | trackedConfigsLock.Lock() 34 | if ctx := trackedConfigs[path]; ctx != nil { 35 | cfg, err := ctx.service.Config() 36 | trackedConfigsLock.Unlock() 37 | if (err != nil || cfg.StartType == windows.SERVICE_DISABLED) && ctx.once.CompareAndSwap(0, 1) { 38 | ctx.done.Done() 39 | cb(path, consts.ConfigStateStopped) 40 | } 41 | continue 42 | } 43 | trackedConfigsLock.Unlock() 44 | serviceName := ServiceNameOfClient(path) 45 | service, err := m.OpenService(serviceName) 46 | if err != nil { 47 | continue 48 | } 49 | go trackService(service, path, cb) 50 | } 51 | return nil 52 | } 53 | 54 | func WatchConfigServices(paths func() []string, cb ConfigStateCallback) (func() error, error) { 55 | m, err := serviceManager() 56 | if err != nil { 57 | return nil, err 58 | } 59 | var subscription uintptr 60 | err = windows.SubscribeServiceChangeNotifications(m.Handle, windows.SC_EVENT_DATABASE_CHANGE, 61 | windows.NewCallback(func(notification uint32, context uintptr) uintptr { 62 | trackExistingConfigs(paths, cb) 63 | return 0 64 | }), 0, &subscription) 65 | if err == nil { 66 | if err = trackExistingConfigs(paths, cb); err != nil { 67 | windows.UnsubscribeServiceChangeNotifications(subscription) 68 | return nil, err 69 | } 70 | return func() error { 71 | err := windows.UnsubscribeServiceChangeNotifications(subscription) 72 | trackedConfigsLock.Lock() 73 | for _, tc := range trackedConfigs { 74 | tc.done.Done() 75 | } 76 | trackedConfigsLock.Unlock() 77 | return err 78 | }, nil 79 | } 80 | return nil, err 81 | } 82 | 83 | func trackService(service *mgr.Service, path string, cb ConfigStateCallback) { 84 | trackedConfigsLock.Lock() 85 | if _, found := trackedConfigs[path]; found { 86 | trackedConfigsLock.Unlock() 87 | service.Close() 88 | return 89 | } 90 | 91 | defer func() { 92 | service.Close() 93 | }() 94 | ctx := &tracker{service: service} 95 | ctx.done.Add(1) 96 | trackedConfigs[path] = ctx 97 | trackedConfigsLock.Unlock() 98 | defer func() { 99 | trackedConfigsLock.Lock() 100 | delete(trackedConfigs, path) 101 | trackedConfigsLock.Unlock() 102 | }() 103 | 104 | var subscription uintptr 105 | lastState := consts.ConfigStateUnknown 106 | var updateState = func(state consts.ConfigState) { 107 | if state != lastState { 108 | cb(path, state) 109 | lastState = state 110 | } 111 | } 112 | err := windows.SubscribeServiceChangeNotifications(service.Handle, windows.SC_EVENT_STATUS_CHANGE, 113 | windows.NewCallback(func(notification uint32, context uintptr) uintptr { 114 | if ctx.once.Load() != 0 { 115 | return 0 116 | } 117 | configState := consts.ConfigStateUnknown 118 | if notification == 0 { 119 | status, err := service.Query() 120 | if err == nil { 121 | configState = svcStateToConfigState(uint32(status.State)) 122 | } 123 | } else { 124 | configState = notifyStateToConfigState(notification) 125 | } 126 | updateState(configState) 127 | return 0 128 | }), 0, &subscription) 129 | if err == nil { 130 | defer windows.UnsubscribeServiceChangeNotifications(subscription) 131 | status, err := service.Query() 132 | if err == nil { 133 | updateState(svcStateToConfigState(uint32(status.State))) 134 | } 135 | ctx.done.Wait() 136 | } else { 137 | cb(path, consts.ConfigStateStopped) 138 | service.Control(svc.Stop) 139 | } 140 | } 141 | 142 | func svcStateToConfigState(s uint32) consts.ConfigState { 143 | switch s { 144 | case windows.SERVICE_STOPPED: 145 | return consts.ConfigStateStopped 146 | case windows.SERVICE_START_PENDING: 147 | return consts.ConfigStateStarting 148 | case windows.SERVICE_STOP_PENDING: 149 | return consts.ConfigStateStopping 150 | case windows.SERVICE_RUNNING: 151 | return consts.ConfigStateStarted 152 | case windows.SERVICE_NO_CHANGE: 153 | return 0 154 | default: 155 | return 0 156 | } 157 | } 158 | 159 | func notifyStateToConfigState(s uint32) consts.ConfigState { 160 | if s&(windows.SERVICE_NOTIFY_STOPPED|windows.SERVICE_NOTIFY_DELETED|windows.SERVICE_NOTIFY_DELETE_PENDING) != 0 { 161 | return consts.ConfigStateStopped 162 | } else if s&windows.SERVICE_NOTIFY_STOP_PENDING != 0 { 163 | return consts.ConfigStateStopping 164 | } else if s&windows.SERVICE_NOTIFY_RUNNING != 0 { 165 | return consts.ConfigStateStarted 166 | } else if s&windows.SERVICE_NOTIFY_START_PENDING != 0 { 167 | return consts.ConfigStateStarting 168 | } else { 169 | return consts.ConfigStateUnknown 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /ui/aboutpage.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | 9 | "github.com/lxn/walk" 10 | . "github.com/lxn/walk/declarative" 11 | 12 | "github.com/koho/frpmgr/i18n" 13 | "github.com/koho/frpmgr/pkg/res" 14 | "github.com/koho/frpmgr/pkg/version" 15 | ) 16 | 17 | type AboutPage struct { 18 | *walk.TabPage 19 | 20 | db *walk.DataBinder 21 | viewModel aboutViewModel 22 | } 23 | 24 | type GithubRelease struct { 25 | TagName string `json:"tag_name"` 26 | HtmlUrl string `json:"html_url"` 27 | } 28 | 29 | type aboutViewModel struct { 30 | GithubRelease 31 | Checking bool 32 | NewVersion bool 33 | TabIcon *walk.Icon 34 | UpdateIcon *walk.Icon 35 | } 36 | 37 | func NewAboutPage() *AboutPage { 38 | ap := new(AboutPage) 39 | ap.viewModel.TabIcon = loadShieldIcon(16) 40 | ap.viewModel.UpdateIcon = loadIcon(res.IconUpdate, 32) 41 | return ap 42 | } 43 | 44 | func (ap *AboutPage) Page() TabPage { 45 | return TabPage{ 46 | AssignTo: &ap.TabPage, 47 | Title: Bind(fmt.Sprintf("vm.NewVersion ? '%s' : '%s'", i18n.Sprintf("New Version!"), i18n.Sprintf("About"))), 48 | Image: Bind("vm.NewVersion ? vm.TabIcon : ''"), 49 | DataBinder: DataBinder{AssignTo: &ap.db, Name: "vm", DataSource: &ap.viewModel}, 50 | Layout: HBox{Margins: Margins{Left: 24, Top: 24, Right: 24, Bottom: 24}, Spacing: 24}, 51 | Children: []Widget{ 52 | ImageView{Image: loadLogoIcon(72), Alignment: AlignHNearVNear}, 53 | Composite{ 54 | Alignment: AlignHNearVNear, 55 | Layout: VBox{MarginsZero: true}, 56 | Children: []Widget{ 57 | Label{Text: AppLocalName, Font: res.TextLarge, TextColor: res.ColorDarkBlue}, 58 | Label{Text: i18n.Sprintf("Version: %s", version.Number)}, 59 | Label{Text: i18n.Sprintf("FRP version: %s", version.FRPVersion)}, 60 | Label{Text: i18n.Sprintf("Built on: %s", version.BuildDate)}, 61 | Composite{ 62 | Layout: HBox{Margins: Margins{Top: 9, Bottom: 9}}, 63 | Children: []Widget{ 64 | PushButton{ 65 | Enabled: Bind("!vm.Checking"), 66 | Text: Bind(fmt.Sprintf("vm.NewVersion ? ' %s' : (vm.Checking ? '%s' : '%s')", 67 | i18n.Sprintf("Download updates"), i18n.Sprintf("Checking for updates"), 68 | i18n.Sprintf("Check for updates"), 69 | )), 70 | Font: res.TextMedium, 71 | OnClicked: func() { 72 | if ap.viewModel.NewVersion { 73 | openPath(ap.viewModel.HtmlUrl) 74 | } else { 75 | ap.checkUpdate(true) 76 | } 77 | }, 78 | Image: Bind("vm.NewVersion ? vm.UpdateIcon : ''"), 79 | MinSize: Size{Width: 200}, 80 | }, 81 | HSpacer{}, 82 | }, 83 | }, 84 | Label{Text: i18n.Sprintf("For comments or to report bugs, please visit the project page:")}, 85 | LinkLabel{ 86 | Alignment: AlignHNearVCenter, 87 | Text: fmt.Sprintf(`%s`, res.ProjectURL, res.ProjectURL), 88 | OnLinkActivated: func(link *walk.LinkLabelLink) { 89 | openPath(link.URL()) 90 | }, 91 | }, 92 | VSpacer{Size: 6}, 93 | Label{Text: i18n.Sprintf("For FRP configuration documentation, please visit the FRP project page:")}, 94 | LinkLabel{ 95 | Alignment: AlignHNearVCenter, 96 | Text: fmt.Sprintf(`%s`, res.FRPProjectURL, res.FRPProjectURL), 97 | OnLinkActivated: func(link *walk.LinkLabelLink) { 98 | openPath(link.URL()) 99 | }, 100 | }, 101 | }, 102 | }, 103 | HSpacer{}, 104 | }, 105 | } 106 | } 107 | 108 | func (ap *AboutPage) OnCreate() { 109 | // Check update at launch 110 | ap.checkUpdate(false) 111 | } 112 | 113 | func (ap *AboutPage) checkUpdate(showErr bool) { 114 | ap.viewModel.Checking = true 115 | ap.db.Reset() 116 | go func() { 117 | var body []byte 118 | resp, err := http.Get(res.UpdateURL) 119 | if err != nil { 120 | goto Fin 121 | } 122 | defer resp.Body.Close() 123 | if body, err = io.ReadAll(resp.Body); err != nil { 124 | goto Fin 125 | } 126 | ap.viewModel.GithubRelease = GithubRelease{} 127 | err = json.Unmarshal(body, &ap.viewModel.GithubRelease) 128 | Fin: 129 | ap.Synchronize(func() { 130 | ap.viewModel.Checking = false 131 | defer ap.db.Reset() 132 | if err != nil || resp.StatusCode != http.StatusOK { 133 | if showErr { 134 | showErrorMessage(ap.Form(), "", i18n.Sprintf("An error occurred while checking for a software update.")) 135 | } 136 | return 137 | } 138 | if ap.viewModel.TagName != "" && ap.viewModel.TagName[1:] != version.Number { 139 | ap.viewModel.NewVersion = true 140 | } else { 141 | ap.viewModel.NewVersion = false 142 | if showErr { 143 | showInfoMessage(ap.Form(), "", i18n.Sprintf("There are currently no updates available.")) 144 | } 145 | } 146 | }) 147 | }() 148 | } 149 | -------------------------------------------------------------------------------- /ui/conf.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | "slices" 8 | 9 | "github.com/lxn/walk" 10 | "github.com/samber/lo" 11 | 12 | "github.com/koho/frpmgr/pkg/config" 13 | "github.com/koho/frpmgr/pkg/consts" 14 | "github.com/koho/frpmgr/pkg/util" 15 | "github.com/koho/frpmgr/services" 16 | ) 17 | 18 | // The flag controls the running state of service. 19 | type runFlag int 20 | 21 | const ( 22 | runFlagAuto runFlag = iota 23 | runFlagForceStart 24 | runFlagReload 25 | ) 26 | 27 | // Conf contains all data of a config 28 | type Conf struct { 29 | // Path of the config file 30 | Path string 31 | // State of service 32 | State consts.ConfigState 33 | // Data is ClientConfig or ServerConfig 34 | Data config.Config 35 | } 36 | 37 | // PathOfConf returns the file path of a config with given base file name 38 | func PathOfConf(base string) string { 39 | return filepath.Join("profiles", base) 40 | } 41 | 42 | func NewConf(path string, data config.Config) *Conf { 43 | if path == "" { 44 | filename, err := util.RandToken(16) 45 | if err != nil { 46 | panic(err) 47 | } 48 | path = PathOfConf(filename + ".conf") 49 | } 50 | return &Conf{ 51 | Path: path, 52 | State: consts.ConfigStateStopped, 53 | Data: data, 54 | } 55 | } 56 | 57 | func (conf *Conf) Name() string { 58 | return conf.Data.Name() 59 | } 60 | 61 | // Delete config will remove service, logs, config file in disk 62 | func (conf *Conf) Delete() error { 63 | // Delete service 64 | running := conf.State == consts.ConfigStateStarted 65 | if err := services.UninstallService(conf.Path, true); err != nil && running { 66 | return err 67 | } 68 | // Delete logs 69 | if logs, _, err := util.FindLogFiles(conf.Data.GetLogFile()); err == nil { 70 | util.DeleteFiles(logs) 71 | } 72 | // Delete config file 73 | if err := os.Remove(conf.Path); err != nil && !errors.Is(err, os.ErrNotExist) { 74 | return err 75 | } 76 | return nil 77 | } 78 | 79 | // Save config to the disk. The config will be completed before saving 80 | func (conf *Conf) Save() error { 81 | logPath, err := filepath.Abs(filepath.Join("logs", util.FileNameWithoutExt(conf.Path)+".log")) 82 | if err != nil { 83 | return err 84 | } 85 | conf.Data.Complete(false) 86 | conf.Data.SetLogFile(filepath.ToSlash(logPath)) 87 | return conf.Data.Save(conf.Path) 88 | } 89 | 90 | var ( 91 | appConf = config.App{Defaults: config.DefaultValue{ 92 | LogLevel: consts.LogLevelInfo, 93 | LogMaxDays: consts.DefaultLogMaxDays, 94 | TCPMux: true, 95 | TLSEnable: true, 96 | }} 97 | confDB *walk.DataBinder 98 | ) 99 | 100 | func loadAllConfs() ([]*Conf, error) { 101 | _ = config.UnmarshalAppConf(config.DefaultAppFile, &appConf) 102 | // Find all config files in `profiles` directory 103 | files, err := filepath.Glob(PathOfConf("*.conf")) 104 | if err != nil { 105 | return nil, err 106 | } 107 | cfgList := make([]*Conf, 0) 108 | for _, f := range files { 109 | if conf, err := config.UnmarshalClientConf(f); err == nil { 110 | c := NewConf(f, conf) 111 | if c.Name() == "" { 112 | conf.ClientCommon.Name = util.FileNameWithoutExt(f) 113 | } 114 | cfgList = append(cfgList, c) 115 | } 116 | } 117 | slices.SortStableFunc(cfgList, func(a, b *Conf) int { 118 | i := slices.Index(appConf.Sort, util.FileNameWithoutExt(a.Path)) 119 | j := slices.Index(appConf.Sort, util.FileNameWithoutExt(b.Path)) 120 | if i < 0 && j >= 0 { 121 | return 1 122 | } else if j < 0 && i >= 0 { 123 | return -1 124 | } 125 | return i - j 126 | }) 127 | return cfgList, nil 128 | } 129 | 130 | // ConfBinder is the view model of configs 131 | type ConfBinder struct { 132 | // Current selected config 133 | Current *Conf 134 | // List of configs 135 | List func() []*Conf 136 | // Set Config state 137 | SetState func(conf *Conf, state consts.ConfigState) bool 138 | // Commit will save the given config and try to reload service 139 | Commit func(conf *Conf, flag runFlag) 140 | } 141 | 142 | // getCurrentConf returns the current selected config 143 | func getCurrentConf() *Conf { 144 | if confDB != nil { 145 | if ds, ok := confDB.DataSource().(*ConfBinder); ok { 146 | return ds.Current 147 | } 148 | } 149 | return nil 150 | } 151 | 152 | // setCurrentConf set the current selected config, the views will get notified 153 | func setCurrentConf(conf *Conf) { 154 | if confDB != nil { 155 | if ds, ok := confDB.DataSource().(*ConfBinder); ok { 156 | ds.Current = conf 157 | confDB.Reset() 158 | } 159 | } 160 | } 161 | 162 | // commitConf will save the given config and try to reload service 163 | func commitConf(conf *Conf, flag runFlag) { 164 | if confDB != nil { 165 | if ds, ok := confDB.DataSource().(*ConfBinder); ok { 166 | ds.Commit(conf, flag) 167 | } 168 | } 169 | } 170 | 171 | // getConfList returns a list of all configs. 172 | func getConfList() []*Conf { 173 | if confDB != nil { 174 | if ds, ok := confDB.DataSource().(*ConfBinder); ok { 175 | return ds.List() 176 | } 177 | } 178 | return nil 179 | } 180 | 181 | func setConfState(conf *Conf, state consts.ConfigState) bool { 182 | if confDB != nil { 183 | if ds, ok := confDB.DataSource().(*ConfBinder); ok { 184 | return ds.SetState(conf, state) 185 | } 186 | } 187 | return false 188 | } 189 | 190 | func newDefaultClientConfig() *config.ClientConfig { 191 | return &config.ClientConfig{ 192 | ClientCommon: appConf.Defaults.AsClientConfig(), 193 | } 194 | } 195 | 196 | func saveAppConfig() error { 197 | return appConf.Save(config.DefaultAppFile) 198 | } 199 | 200 | func setConfOrder(cfgList []*Conf) { 201 | appConf.Sort = lo.Map(cfgList, func(item *Conf, index int) string { 202 | return util.FileNameWithoutExt(item.Path) 203 | }) 204 | saveAppConfig() 205 | } 206 | -------------------------------------------------------------------------------- /ui/confpage.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/lxn/walk" 7 | . "github.com/lxn/walk/declarative" 8 | "github.com/samber/lo" 9 | 10 | "github.com/koho/frpmgr/i18n" 11 | "github.com/koho/frpmgr/pkg/consts" 12 | "github.com/koho/frpmgr/services" 13 | ) 14 | 15 | type ConfPage struct { 16 | *walk.TabPage 17 | 18 | // Views 19 | confView *ConfView 20 | detailView *DetailView 21 | 22 | svcCleanup func() error 23 | } 24 | 25 | func NewConfPage(cfgList []*Conf) *ConfPage { 26 | v := new(ConfPage) 27 | v.confView = NewConfView(cfgList) 28 | v.detailView = NewDetailView() 29 | return v 30 | } 31 | 32 | func (cp *ConfPage) Page() TabPage { 33 | return TabPage{ 34 | AssignTo: &cp.TabPage, 35 | Title: i18n.Sprintf("Configuration"), 36 | Layout: HBox{}, 37 | DataBinder: DataBinder{ 38 | AssignTo: &confDB, 39 | DataSource: &ConfBinder{ 40 | List: cp.confView.model.List, 41 | SetState: cp.confView.model.SetStateByConf, 42 | Commit: func(conf *Conf, flag runFlag) { 43 | if conf != nil { 44 | if err := conf.Save(); err != nil { 45 | showError(err, cp.Form()) 46 | return 47 | } 48 | if flag == runFlagForceStart { 49 | // The service of config is stopped by other code, but it should be restarted 50 | } else if conf.State == consts.ConfigStateStarted { 51 | // Hot-Reloading frp configuration 52 | if flag == runFlagReload { 53 | if err := services.ReloadService(conf.Path); err != nil { 54 | showError(err, cp.Form()) 55 | } 56 | return 57 | } 58 | // The service is running, we should stop it and restart it later 59 | if err := cp.detailView.panelView.StopService(conf); err != nil { 60 | showError(err, cp.Form()) 61 | return 62 | } 63 | } else { 64 | // The service is stopped all the time, there's nothing to do about it 65 | return 66 | } 67 | if err := cp.detailView.panelView.StartService(conf); err != nil { 68 | showError(err, cp.Form()) 69 | return 70 | } 71 | } 72 | }, 73 | }, 74 | Name: "conf", 75 | }, 76 | Children: []Widget{ 77 | cp.confView.View(), 78 | cp.detailView.View(), 79 | cp.welcomeView(), 80 | cp.multiSelectionView(), 81 | }, 82 | } 83 | } 84 | 85 | func (cp *ConfPage) welcomeView() Composite { 86 | return Composite{ 87 | Visible: Bind("confView.SelectedCount == 0"), 88 | Layout: HBox{}, 89 | Children: []Widget{ 90 | HSpacer{}, 91 | Composite{ 92 | Layout: VBox{Spacing: 20}, 93 | Children: []Widget{ 94 | VSpacer{}, 95 | PushButton{ 96 | Text: i18n.Sprintf("New Configuration"), 97 | MinSize: Size{Width: 200}, 98 | OnClicked: cp.confView.editNew, 99 | }, 100 | PushButton{ 101 | Text: i18n.Sprintf("Import from File"), 102 | MinSize: Size{Width: 200}, 103 | OnClicked: cp.confView.onFileImport, 104 | }, 105 | VSpacer{}, 106 | }, 107 | }, 108 | HSpacer{}, 109 | }, 110 | } 111 | } 112 | 113 | func (cp *ConfPage) multiSelectionView() Composite { 114 | count := "{Count}" 115 | text := i18n.Sprintf("Delete %s configs", count) 116 | expr := "confView.SelectedCount" 117 | if i := strings.Index(text, count); i >= 0 { 118 | if left := text[:i]; left != "" { 119 | expr = "'" + left + "' + " + expr 120 | } 121 | if right := text[i+len(count):]; right != "" { 122 | expr += " + '" + right + "'" 123 | } 124 | } 125 | return Composite{ 126 | Visible: Bind("confView.SelectedCount > 1"), 127 | Layout: HBox{}, 128 | Children: []Widget{ 129 | HSpacer{}, 130 | PushButton{ 131 | Text: Bind(expr), 132 | MinSize: Size{Width: 200}, 133 | MaxSize: Size{Width: 200}, 134 | OnClicked: cp.confView.onDelete, 135 | }, 136 | HSpacer{}, 137 | }, 138 | } 139 | } 140 | 141 | func (cp *ConfPage) OnCreate() { 142 | // Create all child views 143 | cp.confView.OnCreate() 144 | cp.detailView.OnCreate() 145 | // Select the first config 146 | if cp.confView.model.RowCount() > 0 { 147 | cp.confView.listView.SetCurrentIndex(0) 148 | } 149 | cp.confView.model.RowEdited().Attach(func(i int) { 150 | cp.detailView.panelView.Invalidate(false) 151 | }) 152 | cp.addVisibleChangedListener() 153 | cleanup, err := services.WatchConfigServices(func() []string { 154 | return lo.Map(getConfList(), func(item *Conf, index int) string { 155 | return item.Path 156 | }) 157 | }, func(path string, state consts.ConfigState) { 158 | cp.Synchronize(func() { 159 | if cp.confView.model.SetStateByPath(path, state) { 160 | if conf := getCurrentConf(); conf != nil && conf.Path == path { 161 | cp.detailView.panelView.setState(state) 162 | if !cp.Visible() { 163 | return 164 | } 165 | if state == consts.ConfigStateStarted { 166 | cp.detailView.proxyView.startTracker(true) 167 | } else { 168 | if cp.detailView.proxyView.stopTracker() { 169 | cp.detailView.proxyView.resetProxyState(-1) 170 | } 171 | } 172 | } 173 | } 174 | }) 175 | }) 176 | if err != nil { 177 | showError(err, cp.Form()) 178 | return 179 | } 180 | cp.svcCleanup = cleanup 181 | } 182 | 183 | func (cp *ConfPage) addVisibleChangedListener() { 184 | var oldState consts.ConfigState 185 | cp.VisibleChanged().Attach(func() { 186 | if cp.Visible() { 187 | defer func() { 188 | oldState = consts.ConfigStateUnknown 189 | }() 190 | if conf := getCurrentConf(); conf != nil { 191 | if conf.State == consts.ConfigStateStarted { 192 | cp.detailView.proxyView.startTracker(true) 193 | } else if oldState == consts.ConfigStateStarted { 194 | cp.detailView.proxyView.resetProxyState(-1) 195 | } 196 | } 197 | } else { 198 | cp.detailView.proxyView.stopTracker() 199 | if conf := getCurrentConf(); conf != nil { 200 | oldState = conf.State 201 | } 202 | } 203 | }) 204 | } 205 | 206 | func (cp *ConfPage) Close() error { 207 | if cp.svcCleanup != nil { 208 | return cp.svcCleanup() 209 | } 210 | cp.detailView.proxyView.stopTracker() 211 | return nil 212 | } 213 | 214 | func warnConfigRemoved(owner walk.Form, name string) { 215 | showWarningMessage(owner, i18n.Sprintf("Config already removed"), i18n.Sprintf("The config \"%s\" already removed.", name)) 216 | } 217 | -------------------------------------------------------------------------------- /ui/detailview.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/lxn/walk" 5 | . "github.com/lxn/walk/declarative" 6 | ) 7 | 8 | type DetailView struct { 9 | *walk.Composite 10 | 11 | panelView *PanelView 12 | proxyView *ProxyView 13 | } 14 | 15 | func NewDetailView() *DetailView { 16 | v := new(DetailView) 17 | v.panelView = NewPanelView() 18 | v.proxyView = NewProxyView() 19 | return v 20 | } 21 | 22 | func (dv *DetailView) View() Widget { 23 | return Composite{ 24 | Visible: Bind("confView.SelectedCount == 1"), 25 | AssignTo: &dv.Composite, 26 | Layout: VBox{Margins: Margins{Left: 5}, SpacingZero: true}, 27 | Children: []Widget{ 28 | dv.panelView.View(), 29 | VSpacer{Size: 6}, 30 | dv.proxyView.View(), 31 | }, 32 | } 33 | } 34 | 35 | func (dv *DetailView) OnCreate() { 36 | // Create all child views 37 | dv.panelView.OnCreate() 38 | dv.proxyView.OnCreate() 39 | dv.proxyView.toolbar.ApplyDPI(dv.DPI()) 40 | confDB.ResetFinished().Attach(dv.Invalidate) 41 | } 42 | 43 | func (dv *DetailView) Invalidate() { 44 | dv.panelView.Invalidate(true) 45 | dv.proxyView.Invalidate() 46 | } 47 | -------------------------------------------------------------------------------- /ui/icon.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "image" 5 | 6 | "github.com/lxn/walk" 7 | 8 | "github.com/koho/frpmgr/pkg/consts" 9 | "github.com/koho/frpmgr/pkg/res" 10 | ) 11 | 12 | var cachedIconsForWidthAndId = make(map[widthAndId]*walk.Icon) 13 | 14 | func loadIcon(id res.Icon, size int) (icon *walk.Icon) { 15 | icon = cachedIconsForWidthAndId[widthAndId{size, id}] 16 | if icon != nil { 17 | return 18 | } 19 | var err error 20 | if id.Dll == "" { 21 | icon, err = walk.NewIconFromResourceIdWithSize(id.Index, walk.Size{Width: size, Height: size}) 22 | } else { 23 | icon, err = walk.NewIconFromSysDLLWithSize(id.Dll, id.Index, size) 24 | } 25 | if err == nil { 26 | cachedIconsForWidthAndId[widthAndId{size, id}] = icon 27 | } 28 | return 29 | } 30 | 31 | type widthAndId struct { 32 | width int 33 | icon res.Icon 34 | } 35 | 36 | type widthAndConfigState struct { 37 | width int 38 | state consts.ConfigState 39 | } 40 | 41 | var cachedIconsForWidthAndConfigState = make(map[widthAndConfigState]*walk.Icon) 42 | 43 | func iconForConfigState(state consts.ConfigState, size int) (icon *walk.Icon) { 44 | icon = cachedIconsForWidthAndConfigState[widthAndConfigState{size, state}] 45 | if icon != nil { 46 | return 47 | } 48 | switch state { 49 | case consts.ConfigStateStarted: 50 | icon = loadIcon(res.IconStateRunning, size) 51 | case consts.ConfigStateStopped, consts.ConfigStateUnknown: 52 | icon = loadIcon(res.IconStateStopped, size) 53 | default: 54 | icon = loadIcon(res.IconStateWorking, size) 55 | } 56 | cachedIconsForWidthAndConfigState[widthAndConfigState{size, state}] = icon 57 | return 58 | } 59 | 60 | type widthAndProxyState struct { 61 | width int 62 | state consts.ProxyState 63 | } 64 | 65 | var cachedIconsForWidthAndProxyState = make(map[widthAndProxyState]*walk.Icon) 66 | 67 | func iconForProxyState(state consts.ProxyState, size int) (icon *walk.Icon) { 68 | icon = cachedIconsForWidthAndProxyState[widthAndProxyState{size, state}] 69 | if icon != nil { 70 | return 71 | } 72 | switch state { 73 | case consts.ProxyStateRunning: 74 | icon = loadIcon(res.IconProxyRunning, size) 75 | case consts.ProxyStateError: 76 | icon = loadIcon(res.IconProxyError, size) 77 | default: 78 | icon = loadIcon(res.IconStateStopped, size) 79 | } 80 | cachedIconsForWidthAndProxyState[widthAndProxyState{size, state}] = icon 81 | return 82 | } 83 | 84 | func loadLogoIcon(size int) *walk.Icon { 85 | return loadIcon(res.IconLogo, size) 86 | } 87 | 88 | func loadShieldIcon(size int) (icon *walk.Icon) { 89 | icon = loadIcon(res.IconNewVersion1, size) 90 | if icon == nil { 91 | icon = loadIcon(res.IconNewVersion2, size) 92 | } 93 | return 94 | } 95 | 96 | func drawCopyIcon(canvas *walk.Canvas, color walk.Color) error { 97 | dpi := canvas.DPI() 98 | point := func(x, y int) walk.Point { 99 | return walk.PointFrom96DPI(walk.Point{X: x, Y: y}, dpi) 100 | } 101 | rectangle := func(x, y, width, height int) walk.Rectangle { 102 | return walk.RectangleFrom96DPI(walk.Rectangle{X: x, Y: y, Width: width, Height: height}, dpi) 103 | } 104 | 105 | brush, err := walk.NewSolidColorBrush(color) 106 | if err != nil { 107 | return err 108 | } 109 | defer brush.Dispose() 110 | 111 | pen, err := walk.NewGeometricPen(walk.PenSolid|walk.PenInsideFrame|walk.PenCapSquare|walk.PenJoinMiter, 2, brush) 112 | if err != nil { 113 | return err 114 | } 115 | defer pen.Dispose() 116 | 117 | bounds := rectangle(5, 5, 8, 9) 118 | startPoint := point(3, 3) 119 | // Ensure the gap between two graphics 120 | if penWidth := walk.IntFrom96DPI(pen.Width(), dpi); bounds.X-(startPoint.X+(penWidth-1)/2) < 2 { 121 | bounds.X++ 122 | bounds.Y++ 123 | } 124 | 125 | if err = canvas.DrawRectanglePixels(pen, bounds); err != nil { 126 | return err 127 | } 128 | // Outer line: (2, 2) -> (10, 2) 129 | if err = canvas.DrawLinePixels(pen, startPoint, point(9, 3)); err != nil { 130 | return err 131 | } 132 | // Outer line: (2, 2) -> (2, 11) 133 | if err = canvas.DrawLinePixels(pen, startPoint, point(3, 10)); err != nil { 134 | return err 135 | } 136 | return nil 137 | } 138 | 139 | // flipIcon rotates an icon 180 degrees. 140 | func flipIcon(id res.Icon, size int) *walk.PaintFuncImage { 141 | size96dpi := walk.Size{Width: size, Height: size} 142 | return walk.NewPaintFuncImagePixels(size96dpi, func(canvas *walk.Canvas, bounds walk.Rectangle) error { 143 | size := walk.SizeFrom96DPI(size96dpi, canvas.DPI()) 144 | bitmap, err := walk.NewBitmapFromIconForDPI(loadIcon(id, size.Width), size, canvas.DPI()) 145 | if err != nil { 146 | return err 147 | } 148 | defer bitmap.Dispose() 149 | img, err := bitmap.ToImage() 150 | if err != nil { 151 | return err 152 | } 153 | rotated := image.NewRGBA(img.Rect) 154 | for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ { 155 | for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ { 156 | rotated.Set(img.Bounds().Max.X-x-1, img.Bounds().Max.Y-y-1, img.At(x, y)) 157 | } 158 | } 159 | bitmap, err = walk.NewBitmapFromImageForDPI(rotated, canvas.DPI()) 160 | if err != nil { 161 | return err 162 | } 163 | defer bitmap.Dispose() 164 | return canvas.DrawImageStretchedPixels(bitmap, bounds) 165 | }) 166 | } 167 | -------------------------------------------------------------------------------- /ui/logpage.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "path/filepath" 5 | "slices" 6 | "sort" 7 | "strings" 8 | "time" 9 | 10 | "github.com/fsnotify/fsnotify" 11 | "github.com/lxn/walk" 12 | . "github.com/lxn/walk/declarative" 13 | "github.com/samber/lo" 14 | 15 | "github.com/koho/frpmgr/i18n" 16 | "github.com/koho/frpmgr/pkg/util" 17 | ) 18 | 19 | type LogPage struct { 20 | *walk.TabPage 21 | 22 | nameModel []*Conf 23 | dateModel ListModel 24 | logModel *LogModel 25 | ch chan logSelect 26 | watcher *fsnotify.Watcher 27 | 28 | // Views 29 | logView *walk.TableView 30 | nameView *walk.ComboBox 31 | dateView *walk.ComboBox 32 | openView *walk.PushButton 33 | } 34 | 35 | type logSelect struct { 36 | paths []string 37 | maxLines int 38 | } 39 | 40 | func NewLogPage() (*LogPage, error) { 41 | lp := &LogPage{ 42 | ch: make(chan logSelect), 43 | } 44 | watcher, err := fsnotify.NewWatcher() 45 | if err != nil { 46 | return nil, err 47 | } 48 | lp.watcher = watcher 49 | return lp, nil 50 | } 51 | 52 | func (lp *LogPage) Page() TabPage { 53 | return TabPage{ 54 | AssignTo: &lp.TabPage, 55 | Title: i18n.Sprintf("Log"), 56 | Layout: VBox{}, 57 | Children: []Widget{ 58 | Composite{ 59 | Layout: HBox{MarginsZero: true}, 60 | Children: []Widget{ 61 | ComboBox{ 62 | AssignTo: &lp.nameView, 63 | StretchFactor: 2, 64 | DisplayMember: "Name", 65 | OnCurrentIndexChanged: lp.switchLogName, 66 | }, 67 | ComboBox{ 68 | AssignTo: &lp.dateView, 69 | StretchFactor: 1, 70 | DisplayMember: "Title", 71 | Format: time.DateOnly, 72 | OnCurrentIndexChanged: lp.switchLogDate, 73 | }, 74 | }, 75 | }, 76 | TableView{ 77 | Name: "log", 78 | AssignTo: &lp.logView, 79 | AlternatingRowBG: true, 80 | LastColumnStretched: true, 81 | HeaderHidden: true, 82 | Columns: []TableViewColumn{{}}, 83 | MultiSelection: true, 84 | ContextMenuItems: []MenuItem{ 85 | Action{ 86 | Text: i18n.Sprintf("Copy"), 87 | Enabled: Bind("log.SelectedCount > 0"), 88 | OnTriggered: func() { 89 | if indexes := lp.logView.SelectedIndexes(); len(indexes) > 0 && lp.logModel != nil { 90 | walk.Clipboard().SetText(strings.Join( 91 | lo.Map(indexes, func(item int, index int) string { 92 | return lp.logModel.Value(item, 0).(string) 93 | }), "\n")) 94 | } 95 | }, 96 | }, 97 | Action{ 98 | Text: i18n.Sprintf("Select all"), 99 | Enabled: Bind("log.SelectedCount < log.ItemCount"), 100 | OnTriggered: func() { 101 | lp.logView.SetSelectedIndexes([]int{-1}) 102 | }, 103 | }, 104 | }, 105 | }, 106 | Composite{ 107 | Layout: HBox{MarginsZero: true}, 108 | Children: []Widget{ 109 | HSpacer{}, 110 | PushButton{ 111 | AssignTo: &lp.openView, 112 | MinSize: Size{Width: 150}, 113 | Text: i18n.Sprintf("Open Log Folder"), 114 | Enabled: false, 115 | OnClicked: func() { 116 | if i := lp.dateView.CurrentIndex(); i >= 0 && i < len(lp.dateModel) { 117 | paths := lp.dateModel[i : i+1] 118 | if i == 0 { 119 | paths = lp.dateModel 120 | } 121 | for _, path := range paths { 122 | if util.FileExists(path.Value) { 123 | openFolder(path.Value) 124 | break 125 | } 126 | } 127 | } 128 | }, 129 | }, 130 | }, 131 | }, 132 | }, 133 | } 134 | } 135 | 136 | func (lp *LogPage) OnCreate() { 137 | lp.VisibleChanged().Attach(lp.onVisibleChanged) 138 | go func() { 139 | // Due to the file caching mechanism, new logs may not be written to 140 | // the disk immediately, and therefore no write events will be received. 141 | // It is still necessary to read files regularly. 142 | ticker := time.NewTicker(time.Second * 5) 143 | defer ticker.Stop() 144 | var path string 145 | var watch bool 146 | for { 147 | select { 148 | case event, ok := <-lp.watcher.Events: 149 | if !ok { 150 | return 151 | } 152 | if path != event.Name { 153 | continue 154 | } 155 | if event.Has(fsnotify.Write) { 156 | lp.refreshLog() 157 | } else if event.Has(fsnotify.Create) { 158 | lp.logView.Synchronize(func() { 159 | if lp.logModel != nil { 160 | lp.logModel.Reset() 161 | } 162 | if !lp.openView.Enabled() { 163 | lp.openView.SetEnabled(true) 164 | } 165 | }) 166 | } 167 | case logs := <-lp.ch: 168 | // Try to avoid duplicate operations 169 | if path != "" && len(logs.paths) > 0 && logs.paths[0] == path { 170 | continue 171 | } 172 | if path != "" { 173 | if watch { 174 | lp.watcher.Remove(filepath.Dir(path)) 175 | } 176 | path = "" 177 | watch = false 178 | } 179 | var model *LogModel 180 | var ok bool 181 | if len(logs.paths) > 0 { 182 | path = logs.paths[0] 183 | watch = logs.maxLines > 0 184 | if watch { 185 | lp.watcher.Add(filepath.Dir(path)) 186 | } 187 | model, ok = NewLogModel(logs.paths, logs.maxLines) 188 | } 189 | lp.Synchronize(func() { 190 | lp.openView.SetEnabled(ok) 191 | lp.logModel = model 192 | if model != nil { 193 | lp.logView.SetModel(model) 194 | lp.scrollToBottom() 195 | } else { 196 | lp.logView.SetModel(nil) 197 | } 198 | }) 199 | case <-ticker.C: 200 | if path != "" && watch { 201 | lp.refreshLog() 202 | } 203 | } 204 | } 205 | }() 206 | } 207 | 208 | func (lp *LogPage) refreshLog() { 209 | lp.logView.Synchronize(func() { 210 | if lp.logModel != nil { 211 | scroll := lp.logModel.RowCount() == 0 || (lp.logView.ItemVisible(lp.logModel.RowCount()-1) && len(lp.logView.SelectedIndexes()) <= 1) 212 | if n, err := lp.logModel.ReadMore(); err == nil && n > 0 && scroll { 213 | lp.scrollToBottom() 214 | } 215 | } 216 | }) 217 | } 218 | 219 | func (lp *LogPage) onVisibleChanged() { 220 | if lp.Visible() { 221 | // Try to avoid duplicate operations 222 | if lp.nameView.CurrentIndex() >= 0 { 223 | return 224 | } 225 | // Refresh config name list 226 | lp.nameModel = getConfList() 227 | lp.nameView.SetModel(lp.nameModel) 228 | if len(lp.nameModel) == 0 { 229 | return 230 | } 231 | // Switch to current config log first 232 | if conf := getCurrentConf(); conf != nil { 233 | if i := slices.Index(lp.nameModel, conf); i >= 0 { 234 | lp.nameView.SetCurrentIndex(i) 235 | return 236 | } 237 | } 238 | // Fallback to the first config log 239 | lp.nameView.SetCurrentIndex(0) 240 | } else { 241 | lp.nameView.SetCurrentIndex(-1) 242 | lp.nameView.SetModel(nil) 243 | lp.nameModel = nil 244 | } 245 | } 246 | 247 | func (lp *LogPage) scrollToBottom() { 248 | if count := lp.logModel.RowCount(); count > 0 { 249 | lp.logView.EnsureItemVisible(count - 1) 250 | } 251 | } 252 | 253 | func (lp *LogPage) switchLogName() { 254 | index := lp.nameView.CurrentIndex() 255 | cleanup := func() { 256 | lp.dateModel = nil 257 | lp.dateView.SetModel(nil) 258 | lp.ch <- logSelect{} 259 | } 260 | if index < 0 || lp.nameModel == nil { 261 | cleanup() 262 | return 263 | } 264 | files, dates, err := util.FindLogFiles(lp.nameModel[index].Data.GetLogFile()) 265 | if err != nil { 266 | cleanup() 267 | return 268 | } 269 | pairs := lo.Zip2(files, dates) 270 | sort.SliceStable(pairs[1:], func(i, j int) bool { 271 | return pairs[i+1].B.After(pairs[j+1].B) 272 | }) 273 | files, dates = lo.Unzip2(pairs) 274 | titles := lo.ToAnySlice(dates) 275 | titles[0] = i18n.Sprintf("Latest") 276 | lp.dateModel = NewListModel(files, titles...) 277 | lp.dateView.SetCurrentIndex(-1) 278 | lp.dateView.SetModel(lp.dateModel) 279 | lp.dateView.SetCurrentIndex(0) 280 | } 281 | 282 | func (lp *LogPage) switchLogDate() { 283 | index := lp.dateView.CurrentIndex() 284 | if index < 0 || lp.dateModel == nil { 285 | return 286 | } 287 | if index == 0 { 288 | lp.ch <- logSelect{ 289 | paths: lo.Map(lp.dateModel, func(item *ListItem, index int) string { 290 | return item.Value 291 | }), 292 | maxLines: 2000, 293 | } 294 | } else { 295 | lp.ch <- logSelect{paths: []string{lp.dateModel[index].Value}, maxLines: -1} 296 | } 297 | } 298 | 299 | func (lp *LogPage) Close() error { 300 | return lp.watcher.Close() 301 | } 302 | -------------------------------------------------------------------------------- /ui/nathole.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fatedier/frp/pkg/nathole" 7 | "github.com/lxn/walk" 8 | . "github.com/lxn/walk/declarative" 9 | 10 | "github.com/koho/frpmgr/i18n" 11 | "github.com/koho/frpmgr/pkg/res" 12 | ) 13 | 14 | type NATDiscoveryDialog struct { 15 | *walk.Dialog 16 | 17 | table *walk.TableView 18 | barView *walk.ProgressBar 19 | 20 | // STUN server address 21 | serverAddr string 22 | closed bool 23 | } 24 | 25 | func NewNATDiscoveryDialog(serverAddr string) *NATDiscoveryDialog { 26 | return &NATDiscoveryDialog{serverAddr: serverAddr} 27 | } 28 | 29 | func (nd *NATDiscoveryDialog) Run(owner walk.Form) (int, error) { 30 | dlg := NewBasicDialog(&nd.Dialog, i18n.Sprintf("NAT Discovery"), loadIcon(res.IconNat, 32), 31 | DataBinder{}, nil, 32 | VSpacer{Size: 1}, 33 | Composite{ 34 | Layout: HBox{MarginsZero: true}, 35 | Children: []Widget{ 36 | Label{Text: i18n.SprintfColon("STUN Server")}, 37 | TextEdit{Text: nd.serverAddr, ReadOnly: true, CompactHeight: true}, 38 | }, 39 | }, 40 | VSpacer{Size: 1}, 41 | TableView{ 42 | Name: "tb", 43 | Visible: false, 44 | AssignTo: &nd.table, 45 | Columns: []TableViewColumn{ 46 | {Title: i18n.Sprintf("Item"), DataMember: "Title", Width: 180}, 47 | {Title: i18n.Sprintf("Value"), DataMember: "Value", Width: 180}, 48 | }, 49 | ColumnsOrderable: false, 50 | }, 51 | ProgressBar{AssignTo: &nd.barView, Visible: Bind("!tb.Visible"), MarqueeMode: true}, 52 | VSpacer{}, 53 | ) 54 | dlg.MinSize = Size{Width: 400, Height: 350} 55 | if err := dlg.Create(owner); err != nil { 56 | return 0, err 57 | } 58 | nd.barView.SetFocus() 59 | nd.Closing().Attach(func(canceled *bool, reason walk.CloseReason) { 60 | nd.closed = true 61 | }) 62 | 63 | // Start discovering NAT type 64 | go nd.discover() 65 | 66 | return nd.Dialog.Run(), nil 67 | } 68 | 69 | func (nd *NATDiscoveryDialog) discover() (err error) { 70 | defer nd.Synchronize(func() { 71 | if err != nil && !nd.closed { 72 | nd.barView.SetMarqueeMode(false) 73 | showError(err, nd.Form()) 74 | nd.Cancel() 75 | } 76 | }) 77 | addrs, localAddr, err := nathole.Discover([]string{nd.serverAddr}, "") 78 | if err != nil { 79 | return err 80 | } 81 | if len(addrs) < 2 { 82 | return fmt.Errorf("can not get enough addresses, need 2, got: %v\n", addrs) 83 | } 84 | 85 | localIPs, _ := nathole.ListLocalIPsForNatHole(10) 86 | 87 | natFeature, err := nathole.ClassifyNATFeature(addrs, localIPs) 88 | if err != nil { 89 | return err 90 | } 91 | items := []*ListItem{ 92 | {Title: i18n.Sprintf("NAT Type"), Value: natFeature.NatType}, 93 | {Title: i18n.Sprintf("Behavior"), Value: natFeature.Behavior}, 94 | {Title: i18n.Sprintf("Local Address"), Value: localAddr.String()}, 95 | } 96 | for _, addr := range addrs { 97 | items = append(items, &ListItem{ 98 | Title: i18n.Sprintf("External Address"), 99 | Value: addr, 100 | }) 101 | } 102 | var public string 103 | if natFeature.PublicNetwork { 104 | public = i18n.Sprintf("Yes") 105 | } else { 106 | public = i18n.Sprintf("No") 107 | } 108 | items = append(items, &ListItem{ 109 | Title: i18n.Sprintf("Public Network"), 110 | Value: public, 111 | }) 112 | nd.table.Synchronize(func() { 113 | nd.table.SetVisible(true) 114 | if err = nd.table.SetModel(NewNonSortedModel(items)); err != nil { 115 | showError(err, nd.Form()) 116 | } 117 | }) 118 | return nil 119 | } 120 | -------------------------------------------------------------------------------- /ui/panelview.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "net/url" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/lxn/walk" 10 | . "github.com/lxn/walk/declarative" 11 | 12 | "github.com/koho/frpmgr/i18n" 13 | "github.com/koho/frpmgr/pkg/config" 14 | "github.com/koho/frpmgr/pkg/consts" 15 | "github.com/koho/frpmgr/pkg/res" 16 | "github.com/koho/frpmgr/pkg/util" 17 | "github.com/koho/frpmgr/services" 18 | ) 19 | 20 | var configStateDescription = map[consts.ConfigState]string{ 21 | consts.ConfigStateUnknown: i18n.Sprintf("Unknown"), 22 | consts.ConfigStateStarted: i18n.Sprintf("Running"), 23 | consts.ConfigStateStopped: i18n.Sprintf("Stopped"), 24 | consts.ConfigStateStarting: i18n.Sprintf("Starting"), 25 | consts.ConfigStateStopping: i18n.Sprintf("Stopping"), 26 | } 27 | 28 | type PanelView struct { 29 | *walk.GroupBox 30 | 31 | stateText *walk.Label 32 | stateImage *walk.ImageView 33 | addressText *walk.Label 34 | protoText *walk.Label 35 | protoImage *walk.ImageView 36 | toggleBtn *walk.PushButton 37 | } 38 | 39 | func NewPanelView() *PanelView { 40 | return new(PanelView) 41 | } 42 | 43 | func (pv *PanelView) View() Widget { 44 | var cpIcon *walk.CustomWidget 45 | cpIconColor := res.ColorDarkGray 46 | setCopyIconColor := func(button walk.MouseButton, color walk.Color) { 47 | if button == walk.LeftButton { 48 | cpIconColor = color 49 | cpIcon.Invalidate() 50 | } 51 | } 52 | return GroupBox{ 53 | AssignTo: &pv.GroupBox, 54 | Title: "", 55 | Layout: Grid{Margins: Margins{Left: 10, Top: 10, Right: 10, Bottom: 10}, Spacing: 10}, 56 | Children: []Widget{ 57 | Label{Text: i18n.SprintfColon("Status"), Row: 0, Column: 0, Alignment: AlignHFarVCenter}, 58 | Label{Text: i18n.SprintfColon("Server Address"), Row: 1, Column: 0, Alignment: AlignHFarVCenter}, 59 | Label{Text: i18n.SprintfColon("Protocol"), Row: 2, Column: 0, Alignment: AlignHFarVCenter}, 60 | Composite{ 61 | Layout: HBox{SpacingZero: true, MarginsZero: true}, 62 | Row: 0, Column: 1, 63 | Alignment: AlignHNearVCenter, 64 | Children: []Widget{ 65 | ImageView{AssignTo: &pv.stateImage, Margin: 0}, 66 | HSpacer{Size: 4}, 67 | Label{AssignTo: &pv.stateText}, 68 | }, 69 | }, 70 | Composite{ 71 | Layout: HBox{SpacingZero: true, MarginsZero: true}, 72 | Row: 1, Column: 1, 73 | Alignment: AlignHNearVCenter, 74 | Children: []Widget{ 75 | Label{AssignTo: &pv.addressText}, 76 | HSpacer{Size: 5}, 77 | CustomWidget{ 78 | AssignTo: &cpIcon, 79 | Background: TransparentBrush{}, 80 | ClearsBackground: true, 81 | InvalidatesOnResize: true, 82 | MinSize: Size{Width: 16, Height: 16}, 83 | ToolTipText: i18n.Sprintf("Copy"), 84 | PaintPixels: func(canvas *walk.Canvas, updateBounds walk.Rectangle) error { 85 | return drawCopyIcon(canvas, cpIconColor) 86 | }, 87 | OnMouseDown: func(x, y int, button walk.MouseButton) { 88 | setCopyIconColor(button, res.ColorLightBlue) 89 | }, 90 | OnMouseUp: func(x, y int, button walk.MouseButton) { 91 | setCopyIconColor(button, res.ColorDarkGray) 92 | bounds := cpIcon.ClientBoundsPixels() 93 | if x >= 0 && x <= bounds.Right() && y >= 0 && y <= bounds.Bottom() { 94 | walk.Clipboard().SetText(pv.addressText.Text()) 95 | } 96 | }, 97 | }, 98 | VSpacer{Size: 20}, 99 | }, 100 | }, 101 | Composite{ 102 | Layout: HBox{Spacing: 2, MarginsZero: true}, 103 | Row: 2, Column: 1, 104 | Alignment: AlignHNearVCenter, 105 | Children: []Widget{ 106 | ImageView{ 107 | AssignTo: &pv.protoImage, 108 | Image: loadIcon(res.IconFlatLock, 14), 109 | ToolTipText: i18n.Sprintf("Your connection to the server is encrypted"), 110 | }, 111 | Label{AssignTo: &pv.protoText}, 112 | }, 113 | }, 114 | Composite{ 115 | Layout: HBox{MarginsZero: true}, 116 | Row: 3, Column: 1, 117 | Alignment: AlignHNearVCenter, 118 | Children: []Widget{ 119 | PushButton{ 120 | AssignTo: &pv.toggleBtn, 121 | Text: i18n.Sprintf("Start"), 122 | MaxSize: Size{Width: 80}, 123 | Enabled: false, 124 | OnClicked: pv.ToggleService, 125 | }, 126 | HSpacer{}, 127 | }, 128 | }, 129 | }, 130 | } 131 | } 132 | 133 | func (pv *PanelView) OnCreate() { 134 | 135 | } 136 | 137 | func (pv *PanelView) setState(state consts.ConfigState) { 138 | pv.stateImage.SetImage(iconForConfigState(state, 14)) 139 | pv.stateText.SetText(configStateDescription[state]) 140 | pv.toggleBtn.SetEnabled(state != consts.ConfigStateStarting && state != consts.ConfigStateStopping && state != consts.ConfigStateUnknown) 141 | if state == consts.ConfigStateStarted || state == consts.ConfigStateStopping { 142 | pv.toggleBtn.SetText(i18n.Sprintf("Stop")) 143 | } else { 144 | pv.toggleBtn.SetText(i18n.Sprintf("Start")) 145 | } 146 | } 147 | 148 | func (pv *PanelView) ToggleService() { 149 | conf := getCurrentConf() 150 | if conf == nil { 151 | return 152 | } 153 | var err error 154 | if conf.State == consts.ConfigStateStarted { 155 | if walk.MsgBox(pv.Form(), i18n.Sprintf("Stop config \"%s\"", conf.Name()), 156 | i18n.Sprintf("Are you sure you would like to stop config \"%s\"?", conf.Name()), 157 | walk.MsgBoxYesNo|walk.MsgBoxIconQuestion) == walk.DlgCmdNo { 158 | return 159 | } 160 | err = pv.StopService(conf) 161 | } else { 162 | if !util.FileExists(conf.Path) { 163 | warnConfigRemoved(pv.Form(), conf.Name()) 164 | return 165 | } 166 | err = pv.StartService(conf) 167 | } 168 | if err != nil { 169 | showError(err, pv.Form()) 170 | } 171 | } 172 | 173 | // StartService creates a daemon service of the given config, then starts it 174 | func (pv *PanelView) StartService(conf *Conf) error { 175 | // Verify the config file 176 | if err := services.VerifyClientConfig(conf.Path); err != nil { 177 | return err 178 | } 179 | // Ensure log directory is valid 180 | if logFile := conf.Data.GetLogFile(); logFile != "" && logFile != "console" { 181 | if err := os.MkdirAll(filepath.Dir(logFile), os.ModePerm); err != nil { 182 | return err 183 | } 184 | } 185 | oldState := conf.State 186 | setConfState(conf, consts.ConfigStateStarting) 187 | pv.setState(consts.ConfigStateStarting) 188 | go func() { 189 | if err := services.InstallService(conf.Name(), conf.Path, !conf.Data.AutoStart()); err != nil { 190 | pv.Synchronize(func() { 191 | showErrorMessage(pv.Form(), i18n.Sprintf("Start config \"%s\"", conf.Name()), err.Error()) 192 | if conf.State == consts.ConfigStateStarting { 193 | setConfState(conf, oldState) 194 | if getCurrentConf() == conf { 195 | pv.setState(oldState) 196 | } 197 | } 198 | }) 199 | } 200 | }() 201 | return nil 202 | } 203 | 204 | // StopService stops the service of the given config, then removes it 205 | func (pv *PanelView) StopService(conf *Conf) (err error) { 206 | oldState := conf.State 207 | setConfState(conf, consts.ConfigStateStopping) 208 | pv.setState(consts.ConfigStateStopping) 209 | defer func() { 210 | if err != nil { 211 | setConfState(conf, oldState) 212 | pv.setState(oldState) 213 | } 214 | }() 215 | err = services.UninstallService(conf.Path, false) 216 | return 217 | } 218 | 219 | // Invalidate updates views using the current config 220 | func (pv *PanelView) Invalidate(state bool) { 221 | conf := getCurrentConf() 222 | if conf == nil { 223 | pv.SetTitle("") 224 | pv.setState(consts.ConfigStateUnknown) 225 | pv.addressText.SetText("") 226 | pv.protoText.SetText("") 227 | pv.protoImage.SetVisible(false) 228 | return 229 | } 230 | data := conf.Data.(*config.ClientConfig) 231 | if pv.Title() != conf.Name() { 232 | pv.SetTitle(conf.Name()) 233 | } 234 | addr := data.ServerAddress 235 | if addr == "" { 236 | addr = "0.0.0.0" 237 | } 238 | if pv.addressText.Text() != addr { 239 | pv.addressText.SetText(addr) 240 | } 241 | pv.protoImage.SetVisible(data.TLSEnable || data.Protocol == consts.ProtoWSS || data.Protocol == consts.ProtoQUIC) 242 | proto := data.Protocol 243 | if proto == "" { 244 | proto = consts.ProtoTCP 245 | } else if proto == consts.ProtoWebsocket { 246 | proto = "ws" 247 | } 248 | proto = strings.ToUpper(proto) 249 | if data.HTTPProxy != "" && data.Protocol != consts.ProtoQUIC { 250 | if u, err := url.Parse(data.HTTPProxy); err == nil { 251 | proto += " + " + strings.ToUpper(u.Scheme) 252 | } 253 | } 254 | if pv.protoText.Text() != proto { 255 | pv.protoText.SetText(proto) 256 | } 257 | if state { 258 | pv.setState(conf.State) 259 | } 260 | } 261 | -------------------------------------------------------------------------------- /ui/pluginproxy.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/lxn/walk" 8 | . "github.com/lxn/walk/declarative" 9 | 10 | "github.com/koho/frpmgr/i18n" 11 | "github.com/koho/frpmgr/pkg/config" 12 | "github.com/koho/frpmgr/pkg/consts" 13 | "github.com/koho/frpmgr/pkg/res" 14 | ) 15 | 16 | type PluginProxyDialog struct { 17 | *walk.Dialog 18 | 19 | Proxies []*config.Proxy 20 | binder *quickAddBinder 21 | db *walk.DataBinder 22 | 23 | // title of the dialog 24 | title string 25 | // icon of the dialog 26 | icon *walk.Icon 27 | // plugin of the proxy 28 | plugin string 29 | } 30 | 31 | // NewPluginProxyDialog creates proxy with given plugin 32 | func NewPluginProxyDialog(title string, icon *walk.Icon, plugin string) *PluginProxyDialog { 33 | return &PluginProxyDialog{ 34 | title: title, 35 | icon: icon, 36 | plugin: plugin, 37 | Proxies: make([]*config.Proxy, 0), 38 | binder: &quickAddBinder{}, 39 | } 40 | } 41 | 42 | func (pp *PluginProxyDialog) Run(owner walk.Form) (int, error) { 43 | widgets := []Widget{ 44 | Label{Text: i18n.SprintfColon("Remote Port")}, 45 | NumberEdit{Value: Bind("RemotePort"), MaxValue: 65535, MinSize: Size{Width: 280}}, 46 | } 47 | switch pp.plugin { 48 | case consts.PluginHttpProxy, consts.PluginSocks5: 49 | pp.binder.Plugin = consts.PluginHttpProxy 50 | widgets = append([]Widget{ 51 | Label{Text: i18n.SprintfColon("Type")}, 52 | NewRadioButtonGroup("Plugin", nil, nil, []RadioButton{ 53 | {Text: "HTTP", Value: consts.PluginHttpProxy}, 54 | {Text: "SOCKS5", Value: consts.PluginSocks5}, 55 | }), 56 | }, widgets...) 57 | case consts.PluginStaticFile: 58 | widgets = append(widgets, 59 | Label{Text: i18n.SprintfColon("Local Directory")}, 60 | NewBrowseLineEdit(nil, true, true, Bind("Dir", res.ValidateNonEmpty), 61 | i18n.Sprintf("Select a folder for directory listing."), "", false), 62 | ) 63 | } 64 | return NewBasicDialog(&pp.Dialog, pp.title, pp.icon, DataBinder{ 65 | AssignTo: &pp.db, 66 | DataSource: pp.binder, 67 | }, pp.onSave, append(widgets, VSpacer{})...).Run(owner) 68 | } 69 | 70 | func (pp *PluginProxyDialog) GetProxies() []*config.Proxy { 71 | return pp.Proxies 72 | } 73 | 74 | func (pp *PluginProxyDialog) onSave() { 75 | if err := pp.db.Submit(); err != nil { 76 | return 77 | } 78 | if pp.binder.Plugin != "" { 79 | pp.plugin = pp.binder.Plugin 80 | } 81 | pp.Proxies = append(pp.Proxies, &config.Proxy{ 82 | BaseProxyConf: config.BaseProxyConf{ 83 | Name: fmt.Sprintf("%s_%d", pp.plugin, pp.binder.RemotePort), 84 | Type: "tcp", 85 | Plugin: pp.plugin, 86 | PluginParams: config.PluginParams{ 87 | PluginLocalPath: pp.binder.Dir, 88 | }, 89 | }, 90 | RemotePort: strconv.Itoa(pp.binder.RemotePort), 91 | }) 92 | pp.Accept() 93 | } 94 | -------------------------------------------------------------------------------- /ui/portproxy.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/lxn/walk" 8 | . "github.com/lxn/walk/declarative" 9 | 10 | "github.com/koho/frpmgr/i18n" 11 | "github.com/koho/frpmgr/pkg/config" 12 | "github.com/koho/frpmgr/pkg/consts" 13 | "github.com/koho/frpmgr/pkg/res" 14 | ) 15 | 16 | type portProxyBinder struct { 17 | quickAddBinder 18 | Name string 19 | TCP bool 20 | UDP bool 21 | } 22 | 23 | type PortProxyDialog struct { 24 | *walk.Dialog 25 | 26 | Proxies []*config.Proxy 27 | binder *portProxyBinder 28 | db *walk.DataBinder 29 | } 30 | 31 | func NewPortProxyDialog() *PortProxyDialog { 32 | dlg := new(PortProxyDialog) 33 | dlg.binder = &portProxyBinder{ 34 | quickAddBinder: quickAddBinder{ 35 | LocalAddr: "127.0.0.1", 36 | }, 37 | TCP: true, 38 | UDP: true, 39 | } 40 | return dlg 41 | } 42 | 43 | func (pp *PortProxyDialog) Run(owner walk.Form) (int, error) { 44 | widgets := []Widget{ 45 | Label{Text: i18n.SprintfColon("Name"), ColumnSpan: 2}, 46 | LineEdit{Text: Bind("Name"), CueBanner: "open_xxx", ColumnSpan: 2}, 47 | Label{Text: i18n.SprintfColon("Remote Port"), ColumnSpan: 2}, 48 | NumberEdit{Value: Bind("RemotePort"), MaxValue: 65535, ColumnSpan: 2}, 49 | Label{Text: i18n.SprintfColon("Protocol"), ColumnSpan: 2}, 50 | Composite{ 51 | Layout: HBox{MarginsZero: true}, 52 | ColumnSpan: 2, 53 | Children: []Widget{ 54 | CheckBox{Text: "TCP", Checked: Bind("TCP")}, 55 | CheckBox{Text: "UDP", Checked: Bind("UDP")}, 56 | }, 57 | }, 58 | Label{Text: i18n.SprintfColon("Local Address")}, 59 | Label{Text: i18n.SprintfColon("Port")}, 60 | LineEdit{Text: Bind("LocalAddr", res.ValidateNonEmpty), StretchFactor: 2}, 61 | NumberEdit{Value: Bind("LocalPort", Range{Min: 1, Max: 65535}), MaxValue: 65535, MinSize: Size{Width: 90}}, 62 | } 63 | return NewBasicDialog(&pp.Dialog, i18n.Sprintf("Open Port"), loadIcon(res.IconOpenPort, 32), DataBinder{ 64 | AssignTo: &pp.db, 65 | DataSource: pp.binder, 66 | }, pp.onSave, Composite{ 67 | Layout: Grid{Columns: 2, MarginsZero: true}, 68 | MinSize: Size{Width: 280}, 69 | Children: widgets, 70 | }, VSpacer{}).Run(owner) 71 | } 72 | 73 | func (pp *PortProxyDialog) GetProxies() []*config.Proxy { 74 | return pp.Proxies 75 | } 76 | 77 | func (pp *PortProxyDialog) onSave() { 78 | if err := pp.db.Submit(); err != nil { 79 | return 80 | } 81 | name := pp.binder.Name 82 | if name == "" { 83 | name = fmt.Sprintf("open_%d", pp.binder.RemotePort) 84 | } 85 | proxy := config.Proxy{ 86 | BaseProxyConf: config.BaseProxyConf{ 87 | Name: name, 88 | LocalIP: pp.binder.LocalAddr, 89 | LocalPort: strconv.Itoa(pp.binder.LocalPort), 90 | }, 91 | RemotePort: strconv.Itoa(pp.binder.RemotePort), 92 | } 93 | if pp.binder.TCP { 94 | tcpProxy := proxy 95 | tcpProxy.Name += "_tcp" 96 | tcpProxy.Type = consts.ProxyTypeTCP 97 | pp.Proxies = append(pp.Proxies, &tcpProxy) 98 | } 99 | if pp.binder.UDP { 100 | udpProxy := proxy 101 | udpProxy.Name += "_udp" 102 | udpProxy.Type = consts.ProxyTypeUDP 103 | pp.Proxies = append(pp.Proxies, &udpProxy) 104 | } 105 | pp.Accept() 106 | } 107 | -------------------------------------------------------------------------------- /ui/prefpage.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "math" 5 | "sort" 6 | 7 | "github.com/lxn/walk" 8 | . "github.com/lxn/walk/declarative" 9 | "github.com/lxn/win" 10 | "github.com/samber/lo" 11 | 12 | "github.com/koho/frpmgr/i18n" 13 | "github.com/koho/frpmgr/pkg/consts" 14 | "github.com/koho/frpmgr/pkg/res" 15 | "github.com/koho/frpmgr/pkg/sec" 16 | "github.com/koho/frpmgr/pkg/validators" 17 | ) 18 | 19 | type PrefPage struct { 20 | *walk.TabPage 21 | 22 | usePassword *walk.CheckBox 23 | } 24 | 25 | func NewPrefPage() *PrefPage { 26 | return new(PrefPage) 27 | } 28 | 29 | func (pp *PrefPage) OnCreate() { 30 | pp.usePassword.CheckedChanged().Attach(pp.switchPassword) 31 | } 32 | 33 | func (pp *PrefPage) Page() TabPage { 34 | return TabPage{ 35 | AssignTo: &pp.TabPage, 36 | Title: i18n.Sprintf("Preferences"), 37 | Layout: VBox{}, 38 | Children: []Widget{ 39 | pp.passwordSection(), 40 | pp.languageSection(), 41 | pp.defaultSection(), 42 | VSpacer{}, 43 | }, 44 | } 45 | } 46 | 47 | func (pp *PrefPage) passwordSection() GroupBox { 48 | return GroupBox{ 49 | Title: i18n.Sprintf("Master password"), 50 | Layout: Grid{Alignment: AlignHNearVCenter, Columns: 2}, 51 | Children: []Widget{ 52 | ImageView{Image: loadIcon(res.IconKey, 32)}, 53 | Label{Text: i18n.Sprintf("You can set a password to restrict access to this program.\nYou will be asked to enter it the next time you use this program.")}, 54 | CheckBox{ 55 | AssignTo: &pp.usePassword, 56 | Name: "usePwd", 57 | Text: i18n.Sprintf("Use master password"), 58 | Checked: appConf.Password != "", 59 | Row: 1, 60 | Column: 1, 61 | }, 62 | Composite{ 63 | Row: 2, Column: 1, 64 | Layout: HBox{MarginsZero: true, Margins: Margins{Top: 5, Bottom: 5}, Spacing: 10}, 65 | Children: []Widget{ 66 | PushButton{ 67 | MinSize: Size{Width: 150}, 68 | Text: i18n.Sprintf("Change Password"), 69 | Enabled: Bind("usePwd.Checked"), 70 | OnClicked: func() { 71 | pp.changePassword() 72 | }, 73 | }, 74 | HSpacer{}, 75 | }, 76 | }, 77 | }, 78 | } 79 | } 80 | 81 | func (pp *PrefPage) languageSection() GroupBox { 82 | keys := make([]string, 0, len(i18n.IDToName)) 83 | for k := range i18n.IDToName { 84 | keys = append(keys, k) 85 | } 86 | sort.SliceStable(keys, func(i, j int) bool { 87 | return keys[i] < keys[j] 88 | }) 89 | names := make([]string, len(keys)) 90 | for i := range keys { 91 | names[i] = i18n.IDToName[keys[i]] 92 | } 93 | var langSelect *walk.ComboBox 94 | return GroupBox{ 95 | Title: i18n.Sprintf("Languages"), 96 | Layout: Grid{Alignment: AlignHNearVCenter, Columns: 2}, 97 | Children: []Widget{ 98 | ImageView{Image: loadIcon(res.IconLanguage, 32)}, 99 | Composite{ 100 | Layout: VBox{MarginsZero: true}, 101 | Children: []Widget{ 102 | Composite{ 103 | Layout: HBox{MarginsZero: true}, 104 | Children: []Widget{ 105 | Label{Text: i18n.SprintfColon("The current display language is")}, 106 | LineEdit{Text: i18n.IDToName[i18n.GetLanguage()], ReadOnly: true, MaxSize: Size{Width: 200}}, 107 | HSpacer{}, 108 | }, 109 | }, 110 | Label{Text: i18n.Sprintf("You must restart program to apply the modification.")}, 111 | }, 112 | }, 113 | Composite{ 114 | Row: 1, Column: 1, 115 | Layout: HBox{Margins: Margins{Top: 5, Bottom: 5}, Spacing: 10}, 116 | Children: []Widget{ 117 | Label{Text: i18n.SprintfColon("Select language")}, 118 | ComboBox{ 119 | AssignTo: &langSelect, 120 | Model: NewListModel(keys, lo.ToAnySlice(names)...), 121 | MinSize: Size{Width: 200}, 122 | DisplayMember: "Title", 123 | BindingMember: "Value", 124 | Value: i18n.GetLanguage(), 125 | OnCurrentIndexChanged: func() { 126 | pp.switchLanguage(keys[langSelect.CurrentIndex()]) 127 | }, 128 | }, 129 | HSpacer{}, 130 | }, 131 | }, 132 | }, 133 | } 134 | } 135 | 136 | func (pp *PrefPage) defaultSection() GroupBox { 137 | return GroupBox{ 138 | Title: i18n.Sprintf("Defaults"), 139 | Layout: Grid{Alignment: AlignHNearVCenter, Columns: 2, Spacing: 10, Margins: Margins{Left: 9, Top: 9, Right: 9, Bottom: 16}}, 140 | Children: []Widget{ 141 | ImageView{Image: loadIcon(res.IconDefaults, 32)}, 142 | Label{Text: i18n.Sprintf("Define the default value when creating a new configuration.\nThe value here will not affect the existing configuration.")}, 143 | Composite{ 144 | Row: 1, Column: 1, 145 | Layout: HBox{MarginsZero: true}, 146 | Children: []Widget{ 147 | PushButton{Text: i18n.Sprintf("Set Defaults"), MinSize: Size{Width: 150}, OnClicked: func() { 148 | if r, err := pp.setDefaultValue(); err == nil && r == win.IDOK { 149 | if err = saveAppConfig(); err != nil { 150 | showError(err, pp.Form()) 151 | } 152 | } 153 | }}, 154 | HSpacer{}, 155 | }, 156 | }, 157 | }, 158 | } 159 | } 160 | 161 | func (pp *PrefPage) switchPassword() { 162 | if pp.usePassword.Checked() { 163 | if newPassword := pp.changePassword(); newPassword == "" && appConf.Password == "" { 164 | pp.usePassword.SetChecked(false) 165 | } 166 | } else { 167 | if appConf.Password != "" { 168 | appConf.Password = "" 169 | if err := saveAppConfig(); err != nil { 170 | showError(err, pp.Form()) 171 | return 172 | } 173 | showInfoMessage(pp.Form(), "", i18n.Sprintf("Password removed.")) 174 | } 175 | } 176 | } 177 | 178 | func (pp *PrefPage) changePassword() string { 179 | var db *walk.DataBinder 180 | var pwdEdit *walk.LineEdit 181 | var vm struct { 182 | Password string 183 | } 184 | NewBasicDialog(nil, i18n.Sprintf("Master password"), loadIcon(res.IconKey, 32), 185 | DataBinder{ 186 | AssignTo: &db, 187 | DataSource: &vm, 188 | ErrorPresenter: validators.SilentToolTipErrorPresenter{}, 189 | }, nil, Composite{ 190 | Layout: VBox{MarginsZero: true}, 191 | MinSize: Size{Width: 280}, 192 | Children: []Widget{ 193 | Label{Text: i18n.SprintfColon("New master password")}, 194 | LineEdit{AssignTo: &pwdEdit, Text: Bind("Password", res.ValidateNonEmpty), PasswordMode: true}, 195 | Label{Text: i18n.SprintfColon("Re-enter password")}, 196 | LineEdit{Text: Bind("", validators.ConfirmPassword{Password: &pwdEdit}), PasswordMode: true}, 197 | }, 198 | }, VSpacer{}).Run(pp.Form()) 199 | if vm.Password != "" { 200 | oldPassword := appConf.Password 201 | appConf.Password = sec.EncryptPassword(vm.Password) 202 | if err := saveAppConfig(); err != nil { 203 | appConf.Password = oldPassword 204 | showError(err, pp.Form()) 205 | } else { 206 | showInfoMessage(pp.Form(), "", i18n.Sprintf("Password is set.")) 207 | } 208 | } 209 | return vm.Password 210 | } 211 | 212 | func (pp *PrefPage) switchLanguage(lc string) { 213 | appConf.Lang = lc 214 | if err := saveAppConfig(); err != nil { 215 | showError(err, pp.Form()) 216 | } 217 | } 218 | 219 | func (pp *PrefPage) setDefaultValue() (int, error) { 220 | dlg := NewBasicDialog(nil, i18n.Sprintf("Defaults"), 221 | loadIcon(res.IconDefaults, 32), 222 | DataBinder{DataSource: &appConf.Defaults}, nil, Composite{ 223 | Layout: Grid{Columns: 2, MarginsZero: true}, 224 | Children: []Widget{ 225 | Label{Text: i18n.SprintfColon("Protocol")}, 226 | ComboBox{ 227 | Name: "proto", 228 | Value: Bind("Protocol"), 229 | Model: consts.Protocols, 230 | }, 231 | Label{Text: i18n.SprintfColon("User")}, 232 | LineEdit{Text: Bind("User")}, 233 | Label{Text: i18n.SprintfColon("Log Level")}, 234 | ComboBox{ 235 | Value: Bind("LogLevel"), 236 | Model: consts.LogLevels, 237 | }, 238 | Label{Text: i18n.SprintfColon("Log retention")}, 239 | NewNumberInput(NIOption{Value: Bind("LogMaxDays"), Suffix: i18n.Sprintf("Days"), Max: math.MaxFloat64}), 240 | Label{Text: i18n.SprintfColon("Auto Delete")}, 241 | NewNumberInput(NIOption{Value: Bind("DeleteAfterDays"), Suffix: i18n.Sprintf("Days"), Max: math.MaxFloat64}), 242 | Label{Text: "DNS:"}, 243 | LineEdit{Text: Bind("DNSServer")}, 244 | Label{Text: i18n.SprintfColon("STUN Server")}, 245 | LineEdit{Text: Bind("NatHoleSTUNServer")}, 246 | Label{Text: i18n.SprintfColon("Source Address")}, 247 | LineEdit{Text: Bind("ConnectServerLocalIP")}, 248 | Composite{ 249 | Layout: VBox{MarginsZero: true, SpacingZero: true}, 250 | Children: []Widget{ 251 | VSpacer{Size: 6}, 252 | Label{Text: i18n.SprintfColon("Other Options"), Alignment: AlignHNearVNear}, 253 | }, 254 | }, 255 | Composite{ 256 | Layout: VBox{MarginsZero: true, SpacingZero: true, Alignment: AlignHNearVNear}, 257 | Children: []Widget{ 258 | Composite{ 259 | Layout: HBox{MarginsZero: true}, 260 | Children: []Widget{ 261 | CheckBox{Text: i18n.Sprintf("TCP Mux"), Checked: Bind("TCPMux")}, 262 | CheckBox{Text: "TLS", Checked: Bind("TLSEnable")}, 263 | }, 264 | }, 265 | CheckBox{Text: i18n.Sprintf("Disable auto-start at boot"), Checked: Bind("ManualStart")}, 266 | CheckBox{Text: i18n.Sprintf("Use legacy file format"), Checked: Bind("LegacyFormat")}, 267 | }, 268 | }, 269 | }, 270 | }, VSpacer{}) 271 | dlg.MinSize = Size{Width: 300} 272 | return dlg.Run(pp.Form()) 273 | } 274 | -------------------------------------------------------------------------------- /ui/proxytracker.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | "time" 7 | 8 | "github.com/fatedier/frp/client/proxy" 9 | "github.com/lxn/walk" 10 | 11 | "github.com/koho/frpmgr/pkg/config" 12 | "github.com/koho/frpmgr/pkg/consts" 13 | "github.com/koho/frpmgr/pkg/ipc" 14 | "github.com/koho/frpmgr/services" 15 | ) 16 | 17 | type ProxyTracker struct { 18 | sync.RWMutex 19 | owner walk.Form 20 | model *ProxyModel 21 | cache map[string]*config.Proxy 22 | ctx context.Context 23 | cancel context.CancelFunc 24 | refreshTimer *time.Timer 25 | client ipc.Client 26 | rowsInsertedHandle int 27 | beforeRemoveHandle int 28 | rowEditedHandle int 29 | rowRenamedHandle int 30 | } 31 | 32 | func NewProxyTracker(owner walk.Form, model *ProxyModel, refresh bool) (tracker *ProxyTracker) { 33 | cache := make(map[string]*config.Proxy) 34 | ctx, cancel := context.WithCancel(context.Background()) 35 | client := ipc.NewPipeClient(services.ServiceNameOfClient(model.conf.Path), func() []string { 36 | tracker.RLock() 37 | defer tracker.RUnlock() 38 | names := make([]string, 0, len(cache)) 39 | for k := range cache { 40 | if !cache[k].Disabled { 41 | names = append(names, k) 42 | } 43 | } 44 | return names 45 | }) 46 | tracker = &ProxyTracker{ 47 | owner: owner, 48 | model: model, 49 | cache: cache, 50 | ctx: ctx, 51 | cancel: cancel, 52 | client: client, 53 | rowsInsertedHandle: model.RowsInserted().Attach(func(from, to int) { 54 | tracker.Lock() 55 | defer tracker.Unlock() 56 | for i := from; i <= to; i++ { 57 | for _, key := range model.items[i].GetAlias() { 58 | cache[key] = model.items[i].Proxy 59 | } 60 | } 61 | client.Probe(ctx) 62 | }), 63 | beforeRemoveHandle: model.BeforeRemove().Attach(func(i int) { 64 | tracker.Lock() 65 | defer tracker.Unlock() 66 | for _, key := range model.items[i].GetAlias() { 67 | delete(cache, key) 68 | } 69 | }), 70 | rowEditedHandle: model.RowEdited().Attach(func(i int) { 71 | client.Probe(ctx) 72 | }), 73 | rowRenamedHandle: model.RowRenamed().Attach(func(i int) { 74 | tracker.buildCache() 75 | }), 76 | } 77 | tracker.buildCache() 78 | client.SetCallback(tracker.onMessage) 79 | go client.Run(ctx) 80 | // If no status information is received within a certain period of time, 81 | // we need to refresh the view to make the icon visible. 82 | if refresh { 83 | tracker.refreshTimer = time.AfterFunc(300*time.Millisecond, func() { 84 | owner.Synchronize(func() { 85 | if ctx.Err() != nil { 86 | return 87 | } 88 | model.PublishRowsChanged(0, len(model.items)-1) 89 | }) 90 | }) 91 | } 92 | return 93 | } 94 | 95 | func (pt *ProxyTracker) onMessage(msg []ipc.ProxyMessage) { 96 | pt.RLock() 97 | defer pt.RUnlock() 98 | stat := make(map[*config.Proxy]ipc.ProxyMessage) 99 | for _, pm := range msg { 100 | pxy, ok := pt.cache[pm.Name] 101 | if !ok { 102 | continue 103 | } 104 | _, priority := proxyPhaseToProxyState(pm.Status) 105 | s, ok := stat[pxy] 106 | if ok { 107 | _, prevPriority := proxyPhaseToProxyState(s.Status) 108 | if prevPriority < priority || (prevPriority == priority && pm.Name < s.Name) { 109 | stat[pxy] = pm 110 | } 111 | } else { 112 | stat[pxy] = pm 113 | } 114 | } 115 | if len(stat) > 0 { 116 | pt.owner.Synchronize(func() { 117 | if pt.ctx.Err() != nil { 118 | return 119 | } 120 | for i, item := range pt.model.items { 121 | if item.Disabled { 122 | continue 123 | } 124 | if m, ok := stat[item.Proxy]; ok { 125 | state, _ := proxyPhaseToProxyState(m.Status) 126 | if item.State != state || item.Error != m.Err || item.RemoteAddr != m.RemoteAddr || item.StateSource != m.Name { 127 | item.State = state 128 | item.Error = m.Err 129 | item.StateSource = m.Name 130 | item.RemoteAddr = m.RemoteAddr 131 | item.UpdateRemotePort() 132 | pt.model.PublishRowChanged(i) 133 | if pt.refreshTimer != nil { 134 | pt.refreshTimer.Stop() 135 | pt.refreshTimer = nil 136 | } 137 | } 138 | } 139 | } 140 | }) 141 | } 142 | } 143 | 144 | func (pt *ProxyTracker) buildCache() { 145 | pt.Lock() 146 | defer pt.Unlock() 147 | clear(pt.cache) 148 | for _, item := range pt.model.items { 149 | for _, name := range item.GetAlias() { 150 | pt.cache[name] = item.Proxy 151 | } 152 | } 153 | } 154 | 155 | func (pt *ProxyTracker) Close() { 156 | pt.model.RowsInserted().Detach(pt.rowsInsertedHandle) 157 | pt.model.BeforeRemove().Detach(pt.beforeRemoveHandle) 158 | pt.model.RowEdited().Detach(pt.rowEditedHandle) 159 | pt.model.RowRenamed().Detach(pt.rowRenamedHandle) 160 | pt.cancel() 161 | if pt.refreshTimer != nil { 162 | pt.refreshTimer.Stop() 163 | pt.refreshTimer = nil 164 | } 165 | } 166 | 167 | func proxyPhaseToProxyState(phase string) (consts.ProxyState, int) { 168 | switch phase { 169 | case proxy.ProxyPhaseRunning: 170 | return consts.ProxyStateRunning, 0 171 | case proxy.ProxyPhaseStartErr, proxy.ProxyPhaseCheckFailed, proxy.ProxyPhaseClosed: 172 | return consts.ProxyStateError, 2 173 | default: 174 | return consts.ProxyStateUnknown, 1 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /ui/quickadd.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "github.com/lxn/walk" 5 | 6 | "github.com/koho/frpmgr/pkg/config" 7 | ) 8 | 9 | // quickAddBinder is the view model of quick-add dialog 10 | type quickAddBinder struct { 11 | RemotePort int 12 | LocalAddr string 13 | LocalPort int 14 | LocalPortMin int 15 | LocalPortMax int 16 | Dir string 17 | Plugin string 18 | } 19 | 20 | // QuickAdd is the interface that must be implemented to build a quick-add dialog 21 | type QuickAdd interface { 22 | // Run a new simple dialog to input few parameters 23 | Run(owner walk.Form) (int, error) 24 | // GetProxies returns the proxies generated by quick-add dialog 25 | GetProxies() []*config.Proxy 26 | } 27 | -------------------------------------------------------------------------------- /ui/simpleproxy.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | 7 | "github.com/lxn/walk" 8 | . "github.com/lxn/walk/declarative" 9 | 10 | "github.com/koho/frpmgr/i18n" 11 | "github.com/koho/frpmgr/pkg/config" 12 | "github.com/koho/frpmgr/pkg/res" 13 | ) 14 | 15 | type SimpleProxyDialog struct { 16 | *walk.Dialog 17 | 18 | Proxies []*config.Proxy 19 | binder *quickAddBinder 20 | db *walk.DataBinder 21 | 22 | // title of the dialog 23 | title string 24 | // icon of the dialog 25 | icon *walk.Icon 26 | // The local service name 27 | service string 28 | // types of the proxy 29 | types []string 30 | } 31 | 32 | // NewSimpleProxyDialog creates proxies connecting to the local service 33 | func NewSimpleProxyDialog(title string, icon *walk.Icon, service string, types []string, port int) *SimpleProxyDialog { 34 | return &SimpleProxyDialog{ 35 | title: title, 36 | icon: icon, 37 | service: service, 38 | types: types, 39 | Proxies: make([]*config.Proxy, 0), 40 | binder: &quickAddBinder{ 41 | LocalAddr: "127.0.0.1", 42 | LocalPort: port, 43 | }, 44 | } 45 | } 46 | 47 | func (sp *SimpleProxyDialog) Run(owner walk.Form) (int, error) { 48 | widgets := []Widget{ 49 | Label{Text: i18n.SprintfColon("Remote Port"), ColumnSpan: 2}, 50 | NumberEdit{Value: Bind("RemotePort"), MaxValue: 65535, ColumnSpan: 2}, 51 | Label{Text: i18n.SprintfColon("Local Address")}, 52 | Label{Text: i18n.SprintfColon("Port")}, 53 | LineEdit{Text: Bind("LocalAddr", res.ValidateNonEmpty), StretchFactor: 2}, 54 | NumberEdit{Value: Bind("LocalPort", Range{Min: 1, Max: 65535}), MaxValue: 65535, MinSize: Size{Width: 90}}, 55 | } 56 | switch sp.service { 57 | case "ftp": 58 | var minPort, maxPort *walk.NumberEdit 59 | widgets = append(widgets, Label{Text: i18n.SprintfColon("Passive Port Range"), ColumnSpan: 2}, Composite{ 60 | Layout: HBox{MarginsZero: true}, 61 | Children: []Widget{ 62 | NumberEdit{ 63 | AssignTo: &minPort, 64 | Value: Bind("LocalPortMin", Range{Min: 1, Max: 65535}), 65 | MaxValue: 65535, 66 | MinSize: Size{Width: 80}, 67 | SpinButtonsVisible: true, 68 | OnValueChanged: func() { 69 | maxPort.SetRange(minPort.Value(), 65535) 70 | }, 71 | }, 72 | Label{Text: "-"}, 73 | NumberEdit{ 74 | AssignTo: &maxPort, 75 | Value: Bind("LocalPortMax"), 76 | MaxValue: 65535, 77 | MinSize: Size{Width: 80}, 78 | SpinButtonsVisible: true, 79 | }, 80 | HSpacer{}, 81 | }, 82 | }) 83 | } 84 | return NewBasicDialog(&sp.Dialog, sp.title, sp.icon, DataBinder{ 85 | AssignTo: &sp.db, 86 | DataSource: sp.binder, 87 | }, sp.onSave, Composite{ 88 | Layout: Grid{Columns: 2, MarginsZero: true}, 89 | MinSize: Size{Width: 280}, 90 | Children: widgets, 91 | }, VSpacer{}).Run(owner) 92 | } 93 | 94 | func (sp *SimpleProxyDialog) GetProxies() []*config.Proxy { 95 | return sp.Proxies 96 | } 97 | 98 | func (sp *SimpleProxyDialog) onSave() { 99 | if err := sp.db.Submit(); err != nil { 100 | return 101 | } 102 | for _, proto := range sp.types { 103 | proxy := config.Proxy{ 104 | BaseProxyConf: config.BaseProxyConf{ 105 | Name: fmt.Sprintf("%s_%s_%d", sp.service, proto, sp.binder.RemotePort), 106 | Type: proto, 107 | LocalIP: sp.binder.LocalAddr, 108 | LocalPort: strconv.Itoa(sp.binder.LocalPort), 109 | }, 110 | RemotePort: strconv.Itoa(sp.binder.RemotePort), 111 | } 112 | if sp.binder.LocalPortMin > 0 && sp.binder.LocalPortMax > 0 { 113 | portRange := fmt.Sprintf("%d-%d", sp.binder.LocalPortMin, sp.binder.LocalPortMax) 114 | proxy.LocalPort += "," + portRange 115 | proxy.RemotePort += "," + portRange 116 | } 117 | sp.Proxies = append(sp.Proxies, &proxy) 118 | } 119 | sp.Accept() 120 | } 121 | -------------------------------------------------------------------------------- /ui/ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/lxn/walk" 9 | . "github.com/lxn/walk/declarative" 10 | "github.com/lxn/win" 11 | "github.com/samber/lo" 12 | "golang.org/x/sys/windows" 13 | 14 | "github.com/koho/frpmgr/i18n" 15 | "github.com/koho/frpmgr/pkg/res" 16 | "github.com/koho/frpmgr/pkg/util" 17 | ) 18 | 19 | const AppName = "FRP Manager" 20 | 21 | var AppLocalName = i18n.Sprintf(AppName) 22 | 23 | func init() { 24 | walk.SetTranslationFunc(func(source string, context ...string) string { 25 | translation := i18n.Sprintf(source) 26 | s1 := strings.ReplaceAll(translation, "%!f(MISSING)", "%.f") 27 | return strings.ReplaceAll(s1, "%!f(BADINDEX)", "%.f") 28 | }) 29 | } 30 | 31 | type FRPManager struct { 32 | *walk.MainWindow 33 | 34 | tabs *walk.TabWidget 35 | confPage *ConfPage 36 | logPage *LogPage 37 | prefPage *PrefPage 38 | aboutPage *AboutPage 39 | } 40 | 41 | func RunUI() error { 42 | var err error 43 | // Make sure the config directory exists. 44 | if err = os.MkdirAll(PathOfConf(""), os.ModePerm); err != nil { 45 | return err 46 | } 47 | cfgList, err := loadAllConfs() 48 | if err != nil { 49 | return err 50 | } 51 | if appConf.Password != "" { 52 | if r, err := NewValidateDialog().Run(); err != nil || r != win.IDOK { 53 | return err 54 | } 55 | } 56 | fm := new(FRPManager) 57 | fm.confPage = NewConfPage(cfgList) 58 | fm.logPage, err = NewLogPage() 59 | if err != nil { 60 | return err 61 | } 62 | fm.prefPage = NewPrefPage() 63 | fm.aboutPage = NewAboutPage() 64 | mw := MainWindow{ 65 | Icon: loadLogoIcon(32), 66 | AssignTo: &fm.MainWindow, 67 | Title: AppLocalName, 68 | Persistent: true, 69 | Visible: false, 70 | Layout: VBox{Margins: Margins{Left: 5, Top: 5, Right: 5, Bottom: 5}}, 71 | Font: res.TextRegular, 72 | Children: []Widget{ 73 | TabWidget{ 74 | AssignTo: &fm.tabs, 75 | Pages: []TabPage{ 76 | fm.confPage.Page(), 77 | fm.logPage.Page(), 78 | fm.prefPage.Page(), 79 | fm.aboutPage.Page(), 80 | }, 81 | }, 82 | }, 83 | OnDropFiles: fm.confPage.confView.ImportFiles, 84 | } 85 | if err = mw.Create(); err != nil { 86 | return err 87 | } 88 | // Initialize child pages 89 | fm.confPage.OnCreate() 90 | fm.logPage.OnCreate() 91 | fm.prefPage.OnCreate() 92 | fm.aboutPage.OnCreate() 93 | // Resize window 94 | fm.SetSizePixels(walk.Size{ 95 | Width: fm.confPage.confView.MinSizePixels().Width + fm.IntFrom96DPI(685), 96 | Height: fm.IntFrom96DPI(525), 97 | }) 98 | fm.SetVisible(true) 99 | fm.Run() 100 | fm.confPage.Close() 101 | fm.logPage.Close() 102 | return nil 103 | } 104 | 105 | func showError(err error, owner walk.Form) bool { 106 | if err == nil { 107 | return false 108 | } 109 | showErrorMessage(owner, "", err.Error()) 110 | return true 111 | } 112 | 113 | func showErrorMessage(owner walk.Form, title, message string) { 114 | if title == "" { 115 | title = AppLocalName 116 | } 117 | walk.MsgBox(owner, title, message, walk.MsgBoxIconError) 118 | } 119 | 120 | func showWarningMessage(owner walk.Form, title, message string) { 121 | walk.MsgBox(owner, title, message, walk.MsgBoxIconWarning) 122 | } 123 | 124 | func showInfoMessage(owner walk.Form, title, message string) { 125 | if title == "" { 126 | title = AppLocalName 127 | } 128 | walk.MsgBox(owner, title, message, walk.MsgBoxIconInformation) 129 | } 130 | 131 | // openPath opens a file or url with default application 132 | func openPath(path string) { 133 | if path == "" { 134 | return 135 | } 136 | win.ShellExecute(0, nil, windows.StringToUTF16Ptr(path), nil, nil, win.SW_SHOWNORMAL) 137 | } 138 | 139 | // openFolder opens the explorer and select the given file 140 | func openFolder(path string) { 141 | if path == "" { 142 | return 143 | } 144 | if absPath, err := filepath.Abs(path); err == nil { 145 | win.ShellExecute(0, nil, windows.StringToUTF16Ptr(`explorer`), 146 | windows.StringToUTF16Ptr(`/select,`+absPath), nil, win.SW_SHOWNORMAL) 147 | } 148 | } 149 | 150 | // openFileDialog shows a file dialog to choose file or directory and sends the selected path to the LineEdit view 151 | func openFileDialog(receiver *walk.LineEdit, title string, filter string, file bool) error { 152 | dlg := walk.FileDialog{ 153 | Filter: filter + res.FilterAllFiles, 154 | Title: title, 155 | } 156 | var ok bool 157 | var err error 158 | if file { 159 | ok, err = dlg.ShowOpen(receiver.Form()) 160 | } else { 161 | ok, err = dlg.ShowBrowseFolder(receiver.Form()) 162 | } 163 | if err != nil { 164 | return err 165 | } 166 | if !ok { 167 | return nil 168 | } 169 | return receiver.SetText(strings.ReplaceAll(dlg.FilePath, "\\", "/")) 170 | } 171 | 172 | // calculateHeadColumnTextWidth returns the estimated display width of the first column 173 | func calculateHeadColumnTextWidth(widgets []Widget, columns int) int { 174 | maxLen := 0 175 | for i := range widgets { 176 | if label, ok := widgets[i].(Label); ok && i%columns == 0 { 177 | if textLen := calculateStringWidth(label.Text.(string)); textLen > maxLen { 178 | maxLen = textLen 179 | } 180 | } 181 | } 182 | return maxLen + 5 183 | } 184 | 185 | // calculateStringWidth returns the estimated display width of the given string 186 | func calculateStringWidth(str string) int { 187 | return lo.Sum(lo.Map(util.RuneSizeInString(str), func(s int, i int) int { 188 | // For better estimation, reduce size for non-ascii character 189 | if s > 1 { 190 | return s - 1 191 | } 192 | return s 193 | })) * 6 194 | } 195 | -------------------------------------------------------------------------------- /ui/urlimport.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "path/filepath" 8 | "strings" 9 | "sync" 10 | 11 | "github.com/lxn/walk" 12 | . "github.com/lxn/walk/declarative" 13 | "github.com/samber/lo" 14 | 15 | "github.com/koho/frpmgr/i18n" 16 | "github.com/koho/frpmgr/pkg/res" 17 | "github.com/koho/frpmgr/pkg/util" 18 | ) 19 | 20 | type URLImportDialog struct { 21 | *walk.Dialog 22 | 23 | db *walk.DataBinder 24 | viewModel urlImportViewModel 25 | 26 | // Views 27 | statusText *walk.Label 28 | 29 | // Items contain the downloaded data from URLs 30 | Items []URLConf 31 | } 32 | 33 | type urlImportViewModel struct { 34 | URLs string 35 | Working bool 36 | } 37 | 38 | // URLConf provides config data downloaded from URL 39 | type URLConf struct { 40 | // Filename is the name of the downloaded file 41 | Filename string 42 | // Zip defines whether the Data is a zip file 43 | Zip bool 44 | // Downloaded raw Data from URL 45 | Data []byte 46 | } 47 | 48 | func NewURLImportDialog() *URLImportDialog { 49 | return &URLImportDialog{Items: make([]URLConf, 0)} 50 | } 51 | 52 | func (ud *URLImportDialog) Run(owner walk.Form) (int, error) { 53 | return NewBasicDialog(&ud.Dialog, i18n.Sprintf("Import from URL"), loadIcon(res.IconURLImport, 32), 54 | DataBinder{AssignTo: &ud.db, DataSource: &ud.viewModel, Name: "vm"}, ud.onImport, 55 | Label{Text: i18n.Sprintf("* Support batch import, one link per line.")}, 56 | TextEdit{ 57 | Enabled: Bind("!vm.Working"), 58 | Text: Bind("URLs", res.ValidateNonEmpty), 59 | VScroll: true, 60 | MinSize: Size{Width: 430, Height: 130}, 61 | }, 62 | Label{ 63 | AssignTo: &ud.statusText, 64 | Text: fmt.Sprintf("%s: %s", i18n.Sprintf("Status"), i18n.Sprintf("Ready")), 65 | EllipsisMode: EllipsisEnd, 66 | }, 67 | VSpacer{Size: 4}, 68 | ).Run(owner) 69 | } 70 | 71 | func (ud *URLImportDialog) onImport() { 72 | if err := ud.db.Submit(); err != nil { 73 | return 74 | } 75 | urls := strings.Split(ud.viewModel.URLs, "\n") 76 | urls = lo.FilterMap(urls, func(s string, i int) (string, bool) { 77 | s = strings.TrimSpace(s) 78 | return s, s != "" 79 | }) 80 | if len(urls) == 0 { 81 | showWarningMessage(ud.Form(), 82 | i18n.Sprintf("Import Config"), 83 | i18n.Sprintf("Please enter the correct URL list.")) 84 | return 85 | } 86 | ud.viewModel.Working = true 87 | ud.DefaultButton().SetEnabled(false) 88 | ud.db.Reset() 89 | 90 | ctx, cancel := context.WithCancel(context.Background()) 91 | var wg sync.WaitGroup 92 | wg.Add(1) 93 | ud.Closing().Attach(func(canceled *bool, reason walk.CloseReason) { 94 | cancel() 95 | wg.Wait() 96 | }) 97 | go ud.urlImport(ctx, &wg, urls) 98 | } 99 | 100 | func (ud *URLImportDialog) urlImport(ctx context.Context, wg *sync.WaitGroup, urls []string) { 101 | result := walk.DlgCmdOK 102 | defer func() { ud.Close(result) }() 103 | defer wg.Done() 104 | for i, url := range urls { 105 | ud.statusText.SetText(fmt.Sprintf("%s: [%d/%d] %s %s", 106 | i18n.Sprintf("Status"), i+1, len(urls), i18n.Sprintf("Download"), url, 107 | )) 108 | filename, mediaType, data, err := util.DownloadFile(ctx, url) 109 | if errors.Is(err, context.Canceled) { 110 | result = walk.DlgCmdCancel 111 | return 112 | } else if err != nil { 113 | showError(err, ud.Form()) 114 | continue 115 | } 116 | ud.Items = append(ud.Items, URLConf{ 117 | Filename: filename, 118 | Zip: mediaType == "application/zip" || strings.ToLower(filepath.Ext(filename)) == ".zip", 119 | Data: data, 120 | }) 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /ui/validate.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "syscall" 7 | "unsafe" 8 | 9 | "github.com/lxn/walk" 10 | "github.com/lxn/win" 11 | "golang.org/x/sys/windows" 12 | 13 | "github.com/koho/frpmgr/i18n" 14 | "github.com/koho/frpmgr/pkg/res" 15 | "github.com/koho/frpmgr/pkg/sec" 16 | ) 17 | 18 | const stmSetIcon = 0x0170 19 | 20 | // ValidateDialog validates the administration password. 21 | type ValidateDialog struct { 22 | hIcon win.HICON 23 | } 24 | 25 | func NewValidateDialog() *ValidateDialog { 26 | return new(ValidateDialog) 27 | } 28 | 29 | func (vd *ValidateDialog) Run() (int, error) { 30 | name, err := syscall.UTF16PtrFromString(res.DialogValidate) 31 | if err != nil { 32 | return -1, err 33 | } 34 | defer func() { 35 | if vd.hIcon != 0 { 36 | win.DestroyIcon(vd.hIcon) 37 | vd.hIcon = 0 38 | } 39 | }() 40 | return win.DialogBoxParam(win.GetModuleHandle(nil), name, 0, syscall.NewCallback(vd.proc), 0), nil 41 | } 42 | 43 | func (vd *ValidateDialog) proc(h win.HWND, msg uint32, wp, lp uintptr) uintptr { 44 | switch msg { 45 | case win.WM_INITDIALOG: 46 | SetWindowText(h, fmt.Sprintf("%s - %s", i18n.Sprintf("Enter Password"), AppLocalName)) 47 | SetWindowText(win.GetDlgItem(h, res.DialogTitle), i18n.Sprintf("You must enter an administration password to operate the %s.", AppLocalName)) 48 | SetWindowText(win.GetDlgItem(h, res.DialogStatic1), i18n.Sprintf("Enter Administration Password")) 49 | SetWindowText(win.GetDlgItem(h, res.DialogStatic2), i18n.SprintfColon("Password")) 50 | SetWindowText(win.GetDlgItem(h, win.IDOK), i18n.Sprintf("OK")) 51 | SetWindowText(win.GetDlgItem(h, win.IDCANCEL), i18n.Sprintf("Cancel")) 52 | vd.setIcon(h, int(win.GetDpiForWindow(h))) 53 | return win.TRUE 54 | case win.WM_COMMAND: 55 | switch win.LOWORD(uint32(wp)) { 56 | case win.IDOK: 57 | passwd := GetWindowText(win.GetDlgItem(h, res.DialogEdit)) 58 | if sec.EncryptPassword(passwd) != appConf.Password { 59 | win.MessageBox(h, windows.StringToUTF16Ptr(i18n.Sprintf("The password is incorrect. Re-enter password.")), 60 | windows.StringToUTF16Ptr(AppLocalName), windows.MB_ICONERROR) 61 | win.SetFocus(win.GetDlgItem(h, res.DialogEdit)) 62 | } else { 63 | win.EndDialog(h, win.IDOK) 64 | } 65 | case win.IDCANCEL: 66 | win.SendMessage(h, win.WM_CLOSE, 0, 0) 67 | } 68 | case win.WM_CTLCOLORBTN, win.WM_CTLCOLORDLG, win.WM_CTLCOLOREDIT, win.WM_CTLCOLORMSGBOX, win.WM_CTLCOLORSTATIC: 69 | return uintptr(win.GetStockObject(win.WHITE_BRUSH)) 70 | case win.WM_DPICHANGED: 71 | vd.setIcon(h, int(win.HIWORD(uint32(wp)))) 72 | case win.WM_CLOSE: 73 | win.EndDialog(h, win.IDCANCEL) 74 | } 75 | return win.FALSE 76 | } 77 | 78 | func (vd *ValidateDialog) setIcon(h win.HWND, dpi int) error { 79 | system32, err := windows.GetSystemDirectory() 80 | if err != nil { 81 | return err 82 | } 83 | iconFile, err := syscall.UTF16PtrFromString(filepath.Join(system32, res.IconKey.Dll+".dll")) 84 | if err != nil { 85 | return err 86 | } 87 | if vd.hIcon != 0 { 88 | win.DestroyIcon(vd.hIcon) 89 | vd.hIcon = 0 90 | } 91 | size := walk.SizeFrom96DPI(walk.Size{Width: 32, Height: 32}, dpi) 92 | win.SHDefExtractIcon(iconFile, int32(res.IconKey.Index), 93 | 0, nil, &vd.hIcon, win.MAKELONG(0, uint16(size.Width))) 94 | if vd.hIcon != 0 { 95 | win.SendDlgItemMessage(h, res.DialogIcon, stmSetIcon, uintptr(vd.hIcon), 0) 96 | } 97 | return nil 98 | } 99 | 100 | func SetWindowText(hWnd win.HWND, text string) bool { 101 | txt, err := syscall.UTF16PtrFromString(text) 102 | if err != nil { 103 | return false 104 | } 105 | if win.TRUE != win.SendMessage(hWnd, win.WM_SETTEXT, 0, uintptr(unsafe.Pointer(txt))) { 106 | return false 107 | } 108 | return true 109 | } 110 | 111 | func GetWindowText(hWnd win.HWND) string { 112 | textLength := win.SendMessage(hWnd, win.WM_GETTEXTLENGTH, 0, 0) 113 | buf := make([]uint16, textLength+1) 114 | win.SendMessage(hWnd, win.WM_GETTEXT, textLength+1, uintptr(unsafe.Pointer(&buf[0]))) 115 | return syscall.UTF16ToString(buf) 116 | } 117 | --------------------------------------------------------------------------------