├── .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 | [](https://github.com/koho/frpmgr/releases)
4 | [](https://github.com/fatedier/frp)
5 | [](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 | 
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 | [](https://github.com/koho/frpmgr/releases)
4 | [](https://github.com/fatedier/frp)
5 | [](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 | 
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 |
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 |
--------------------------------------------------------------------------------