├── installer
├── .gitignore
├── msi
│ ├── zh-CN.wxl
│ ├── zh-TW.wxl
│ ├── ko-KR.wxl
│ ├── ja-JP.wxl
│ ├── en-US.wxl
│ ├── es-ES.wxl
│ └── frpmgr.wxs
├── setup
│ ├── resource.h
│ ├── manifest.xml
│ └── resource.rc
├── actions
│ ├── manifest.xml
│ └── version.rc
└── build.bat
├── .gitignore
├── generate.go
├── icon
├── app.ico
├── dot.ico
├── dot.svg
└── app.svg
├── docs
├── donate-wechat.jpg
├── screenshot_en.png
├── screenshot_zh.png
└── sponsor_signpath.png
├── .github
├── dependabot.yml
├── ISSUE_TEMPLATE
│ ├── feature_request.md
│ └── bug_report.md
├── workflows
│ ├── golangci-lint.yml
│ ├── tests.yml
│ └── releaser.yml
└── FUNDING.yml
├── pkg
├── sec
│ ├── passwd_test.go
│ └── passwd.go
├── version
│ └── version.go
├── consts
│ ├── state.go
│ └── config.go
├── ipc
│ ├── client.go
│ ├── server.go
│ └── pipe.go
├── validators
│ ├── regexp.go
│ ├── passwd.go
│ ├── regexp_test.go
│ └── presenter.go
├── util
│ ├── strings_test.go
│ ├── strings.go
│ ├── misc_test.go
│ ├── file_test.go
│ ├── misc.go
│ ├── file.go
│ └── net.go
├── layout
│ ├── greedy_test.go
│ └── greedy.go
├── config
│ ├── conf_test.go
│ ├── app_test.go
│ ├── v1.go
│ ├── client_test.go
│ ├── conf.go
│ └── app.go
└── res
│ └── res.go
├── cmd
└── frpmgr
│ ├── resource.h
│ ├── manifest.xml
│ ├── main.go
│ ├── singleton.go
│ └── resource.rc
├── .golangci.yml
├── ui
├── quickadd.go
├── detailview.go
├── pluginproxy.go
├── portproxy.go
├── nathole.go
├── urlimport.go
├── simpleproxy.go
├── validate.go
├── properties.go
├── aboutpage.go
├── icon.go
├── proxytracker.go
├── conf.go
├── confpage.go
├── ui.go
├── logpage.go
└── panelview.go
├── services
├── frp.go
├── install.go
├── client.go
├── service.go
└── tracker.go
├── resource.go
├── go.mod
├── i18n
└── text.go
├── README_zh.md
├── CHANGELOG.md
└── README.md
/installer/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.ini
2 | logs
3 | bin
4 | *.syso
5 |
--------------------------------------------------------------------------------
/generate.go:
--------------------------------------------------------------------------------
1 | package frpmgr
2 |
3 | //go:generate go run resource.go
4 |
--------------------------------------------------------------------------------
/icon/app.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koho/frpmgr/HEAD/icon/app.ico
--------------------------------------------------------------------------------
/icon/dot.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koho/frpmgr/HEAD/icon/dot.ico
--------------------------------------------------------------------------------
/docs/donate-wechat.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koho/frpmgr/HEAD/docs/donate-wechat.jpg
--------------------------------------------------------------------------------
/docs/screenshot_en.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koho/frpmgr/HEAD/docs/screenshot_en.png
--------------------------------------------------------------------------------
/docs/screenshot_zh.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koho/frpmgr/HEAD/docs/screenshot_zh.png
--------------------------------------------------------------------------------
/docs/sponsor_signpath.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/koho/frpmgr/HEAD/docs/sponsor_signpath.png
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | updates:
4 | - package-ecosystem: "gomod"
5 | directory: "/"
6 | schedule:
7 | interval: "daily"
8 |
--------------------------------------------------------------------------------
/icon/dot.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/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/version/version.go:
--------------------------------------------------------------------------------
1 | package version
2 |
3 | import (
4 | "github.com/fatedier/frp/pkg/util/version"
5 | )
6 |
7 | var (
8 | Number = "1.24.0"
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/msi/ko-KR.wxl:
--------------------------------------------------------------------------------
1 |
2 |
3 | 1042
4 | FRP 관리자
5 | 이 애플리케이션은 Windows 10, Windows Server 2016 이상에서만 지원됩니다.
6 |
7 |
--------------------------------------------------------------------------------
/installer/msi/ja-JP.wxl:
--------------------------------------------------------------------------------
1 |
2 |
3 | 1041
4 | FRP マネージャ
5 | このアプリケーションは、Windows 10、Windows Server 2016 以降でのみサポートされています。
6 |
7 |
--------------------------------------------------------------------------------
/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/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 | #define IDS_TITLE 200
11 | #define IDS_MANAGEMENT 201
12 | #define IDS_OPERATION 202
13 | #define IDS_REINSTALL 203
14 | #define IDS_UNINSTALL 204
15 |
16 | #endif
17 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/installer/actions/manifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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.24'
22 |
23 | - name: Run
24 | uses: golangci/golangci-lint-action@v8
25 | with:
26 | version: v2.5
27 |
--------------------------------------------------------------------------------
/.golangci.yml:
--------------------------------------------------------------------------------
1 | version: "2"
2 | linters:
3 | default: none
4 | enable:
5 | - govet
6 | - ineffassign
7 | - staticcheck
8 | - unused
9 | - whitespace
10 | exclusions:
11 | generated: lax
12 | presets:
13 | - comments
14 | - common-false-positives
15 | - legacy
16 | - std-error-handling
17 | rules:
18 | - linters:
19 | - staticcheck
20 | text: should not use dot imports
21 | paths:
22 | - third_party$
23 | - builtin$
24 | - examples$
25 | formatters:
26 | exclusions:
27 | generated: lax
28 | paths:
29 | - third_party$
30 | - builtin$
31 | - examples$
32 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 errSilent
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/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 errSilent
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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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, errSilent) {
14 | t.Errorf("Expected: %v, got: %v", errSilent, 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/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/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/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/validators/presenter.go:
--------------------------------------------------------------------------------
1 | package validators
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/lxn/walk"
7 | )
8 |
9 | var errSilent = 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, errSilent) {
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/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/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 |
--------------------------------------------------------------------------------
/services/frp.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "os"
5 |
6 | frpconfig "github.com/fatedier/frp/pkg/config"
7 | "github.com/fatedier/frp/pkg/config/v1/validation"
8 |
9 | "github.com/koho/frpmgr/pkg/util"
10 | )
11 |
12 | func deleteFrpFiles(serviceName, configPath, logFile string) {
13 | // Delete logs
14 | if logs, _, err := util.FindLogFiles(logFile); err == nil {
15 | util.DeleteFiles(logs)
16 | }
17 | // Delete config file
18 | os.Remove(configPath)
19 | // Delete service
20 | m, err := serviceManager()
21 | if err != nil {
22 | return
23 | }
24 | defer m.Disconnect()
25 | service, err := m.OpenService(serviceName)
26 | if err != nil {
27 | return
28 | }
29 | defer service.Close()
30 | service.Delete()
31 | }
32 |
33 | // VerifyClientConfig validates the frp client config file
34 | func VerifyClientConfig(path string) error {
35 | cfg, proxyCfgs, visitorCfgs, _, err := frpconfig.LoadClientConfig(path, false)
36 | if err != nil {
37 | return err
38 | }
39 | _, err = validation.ValidateAllClientConfig(cfg, proxyCfgs, visitorCfgs)
40 | return err
41 | }
42 |
--------------------------------------------------------------------------------
/installer/actions/version.rc:
--------------------------------------------------------------------------------
1 | #include
2 |
3 | #pragma code_page(65001) // UTF-8
4 |
5 | #define STRINGIZE(x) #x
6 | #define EXPAND(x) STRINGIZE(x)
7 |
8 | VS_VERSION_INFO VERSIONINFO
9 | FILEVERSION VERSION_ARRAY
10 | PRODUCTVERSION VERSION_ARRAY
11 | FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
12 | FILEFLAGS 0x0
13 | FILEOS VOS__WINDOWS32
14 | FILETYPE VFT_DLL
15 | FILESUBTYPE VFT2_UNKNOWN
16 | BEGIN
17 | BLOCK "StringFileInfo"
18 | BEGIN
19 | BLOCK "040904b0"
20 | BEGIN
21 | VALUE "CompanyName", "FRP Manager Project"
22 | VALUE "FileDescription", "FRP Manager Setup Custom Actions"
23 | VALUE "FileVersion", EXPAND(VERSION_STR)
24 | VALUE "InternalName", "frpmgr-actions"
25 | VALUE "LegalCopyright", "Copyright © FRP Manager Project"
26 | VALUE "OriginalFilename", "actions.dll"
27 | VALUE "ProductName", "FRP Manager"
28 | VALUE "ProductVersion", EXPAND(VERSION_STR)
29 | VALUE "Comments", "https://github.com/koho/frpmgr"
30 | END
31 | END
32 | BLOCK "VarFileInfo"
33 | BEGIN
34 | VALUE "Translation", 0x409, 1200
35 | END
36 | END
37 |
38 | ISOLATIONAWARE_MANIFEST_RESOURCE_ID RT_MANIFEST manifest.xml
39 |
--------------------------------------------------------------------------------
/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 | "legacyFormat": true
20 | }
21 | }
22 | `
23 | if err := os.WriteFile(DefaultAppFile, []byte(input), 0666); err != nil {
24 | t.Fatal(err)
25 | }
26 | expected := App{
27 | Password: "abcde",
28 | Defaults: DefaultValue{
29 | LogLevel: "info",
30 | LogMaxDays: 5,
31 | Protocol: "kcp",
32 | User: "user",
33 | TCPMux: true,
34 | ManualStart: true,
35 | LegacyFormat: true,
36 | },
37 | }
38 | expectedLang := "en-US"
39 | if err := os.WriteFile(LangFile, []byte(expectedLang), 0666); err != nil {
40 | t.Fatal(err)
41 | }
42 | var actual App
43 | lang, err := UnmarshalAppConf(DefaultAppFile, &actual)
44 | if err != nil {
45 | t.Fatal(err)
46 | }
47 | if !reflect.DeepEqual(actual, expected) {
48 | t.Errorf("Expected: %v, got: %v", expected, actual)
49 | }
50 | if lang == nil || *lang != expectedLang {
51 | t.Errorf("Expected: %v, got: %v", expectedLang, lang)
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/ui/detailview.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "math"
5 |
6 | "github.com/lxn/walk"
7 | . "github.com/lxn/walk/declarative"
8 | )
9 |
10 | type DetailView struct {
11 | *walk.Composite
12 |
13 | panelView *PanelView
14 | proxyView *ProxyView
15 | }
16 |
17 | func NewDetailView() *DetailView {
18 | v := new(DetailView)
19 | v.panelView = NewPanelView()
20 | v.proxyView = NewProxyView()
21 | return v
22 | }
23 |
24 | func (dv *DetailView) View() Widget {
25 | return Composite{
26 | Visible: Bind("confView.SelectedCount == 1"),
27 | AssignTo: &dv.Composite,
28 | Layout: VBox{Margins: Margins{Left: 5}, SpacingZero: true},
29 | Children: []Widget{
30 | dv.panelView.View(),
31 | VSpacer{Size: 6},
32 | dv.proxyView.View(),
33 | },
34 | }
35 | }
36 |
37 | func (dv *DetailView) OnCreate() {
38 | // Create all child views
39 | dv.panelView.OnCreate()
40 | dv.proxyView.OnCreate()
41 | dv.proxyView.toolbar.ApplyDPI(dv.DPI())
42 | confDB.ResetFinished().Attach(dv.Invalidate)
43 | }
44 |
45 | // sizeBias is the pixel offset used to resize the window to match the size of the proxy table.
46 | func (dv *DetailView) sizeBias() walk.Size {
47 | tableHeight := math.Phi * float64(dv.panelView.SizeHint().Height)
48 | return walk.Size{
49 | Width: dv.proxyView.minWidthBias(),
50 | Height: int(tableHeight) - dv.proxyView.table.MinSizeHint().Height,
51 | }
52 | }
53 |
54 | func (dv *DetailView) Invalidate() {
55 | dv.panelView.Invalidate(true)
56 | dv.proxyView.Invalidate()
57 | }
58 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 versionArray = strings.ReplaceAll(version.Number, ".", ",")
19 |
20 | func main() {
21 | rcFiles, err := filepath.Glob("cmd/*/*.rc")
22 | if err != nil {
23 | println(err.Error())
24 | os.Exit(1)
25 | }
26 | arch := os.Getenv("FRPMGR_TARGET")
27 | if arch == "" {
28 | arch = os.Getenv("GOARCH")
29 | }
30 | for _, arch := range strings.Split(arch, " ") {
31 | var args []string
32 | var goArch string
33 | switch strings.TrimSpace(arch) {
34 | case "x64", "amd64":
35 | goArch = "amd64"
36 | args = append(args, "windres", "-F", "pe-x86-64")
37 | case "x86", "386":
38 | goArch = "386"
39 | args = append(args, "windres", "-F", "pe-i386")
40 | case "arm64":
41 | goArch = "arm64"
42 | args = append(args, "aarch64-w64-mingw32-windres")
43 | default:
44 | continue
45 | }
46 | for _, rc := range rcFiles {
47 | output := strings.TrimSuffix(rc, filepath.Ext(rc)) + fmt.Sprintf("_windows_%s.syso", goArch)
48 | res, err := exec.Command(args[0], append([]string{
49 | "-DVERSION_ARRAY=" + versionArray, "-DVERSION_STR=" + version.Number,
50 | "-i", rc, "-o", output, "-O", "coff", "-c", "65001",
51 | }, args[1:]...)...).CombinedOutput()
52 | if err != nil {
53 | println(err.Error(), string(res))
54 | os.Exit(1)
55 | }
56 | }
57 | }
58 | fmt.Println("VERSION=" + version.Number)
59 | fmt.Println("BUILD_DATE=" + time.Now().Format(time.DateOnly))
60 | }
61 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 | type AutoDelete struct {
19 | // DeleteMethod specifies what delete method to use to delete the config.
20 | // If "absolute" is specified, the expiry date is set in config. If "relative" is specified, the expiry date
21 | // is calculated by adding the days to the file modification time. If it's empty, the config has no expiry date.
22 | DeleteMethod string `ini:"frpmgr_delete_method,omitempty" json:"method,omitempty"`
23 | // DeleteAfterDays is the number of days a config will be kept, after which it may be stopped and deleted.
24 | DeleteAfterDays int64 `ini:"frpmgr_delete_after_days,omitempty" relative:"true" json:"afterDays,omitempty"`
25 | // DeleteAfterDate is the last date the config will be valid, after which it may be stopped and deleted.
26 | DeleteAfterDate time.Time `ini:"frpmgr_delete_after_date,omitempty" absolute:"true" json:"afterDate,omitempty"`
27 | }
28 |
29 | func (ad AutoDelete) Complete() AutoDelete {
30 | deleteMethod := ad.DeleteMethod
31 | if deleteMethod != "" {
32 | if d, err := util.PruneByTag(ad, "true", deleteMethod); err == nil {
33 | ad = d.(AutoDelete)
34 | ad.DeleteMethod = deleteMethod
35 | }
36 | // Reset zero day
37 | if deleteMethod == consts.DeleteRelative && ad.DeleteAfterDays == 0 {
38 | ad.DeleteMethod = ""
39 | }
40 | } else {
41 | ad = AutoDelete{}
42 | }
43 | return ad
44 | }
45 |
46 | // Expiry returns the remaining duration, after which a config will expire.
47 | // If a config has no expiry date, an `ErrNoDeadline` error is returned.
48 | func Expiry(configPath string, del AutoDelete) (time.Duration, error) {
49 | fInfo, err := os.Stat(configPath)
50 | if err != nil {
51 | return 0, err
52 | }
53 | switch del.DeleteMethod {
54 | case consts.DeleteAbsolute:
55 | return time.Until(del.DeleteAfterDate), nil
56 | case consts.DeleteRelative:
57 | if del.DeleteAfterDays > 0 {
58 | elapsed := time.Since(fInfo.ModTime())
59 | total := time.Hour * 24 * time.Duration(del.DeleteAfterDays)
60 | return total - elapsed, nil
61 | }
62 | }
63 | return 0, os.ErrNoDeadline
64 | }
65 |
--------------------------------------------------------------------------------
/icon/app.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/pkg/util/misc.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "fmt"
5 | "reflect"
6 | "strings"
7 | )
8 |
9 | // PruneByTag returns a copy of "in" that only contains fields with the given tag and value
10 | func PruneByTag(in interface{}, value string, tag string) (interface{}, error) {
11 | inValue := reflect.ValueOf(in)
12 |
13 | ret := reflect.New(inValue.Type()).Elem()
14 |
15 | if err := prune(inValue, ret, value, tag); err != nil {
16 | return nil, err
17 | }
18 | return ret.Interface(), nil
19 | }
20 |
21 | func prune(inValue reflect.Value, ret reflect.Value, value string, tag string) error {
22 | switch inValue.Kind() {
23 | case reflect.Ptr:
24 | if inValue.IsNil() {
25 | return nil
26 | }
27 | if ret.IsNil() {
28 | // init ret and go to next level
29 | ret.Set(reflect.New(inValue.Type().Elem()))
30 | }
31 | return prune(inValue.Elem(), ret.Elem(), value, tag)
32 | case reflect.Struct:
33 | var fValue reflect.Value
34 | var fRet reflect.Value
35 | // search tag that has key equal to value
36 | for i := 0; i < inValue.NumField(); i++ {
37 | f := inValue.Type().Field(i)
38 | if key, ok := f.Tag.Lookup(tag); ok {
39 | if key == "*" || key == value {
40 | fValue = inValue.Field(i)
41 | fRet = ret.Field(i)
42 | fRet.Set(fValue)
43 | }
44 | }
45 | }
46 | }
47 | return nil
48 | }
49 |
50 | func GetMapWithoutPrefix(set map[string]string, prefix string) map[string]string {
51 | m := make(map[string]string)
52 |
53 | for key, value := range set {
54 | if strings.HasPrefix(key, prefix) {
55 | m[strings.TrimPrefix(key, prefix)] = value
56 | }
57 | }
58 |
59 | if len(m) == 0 {
60 | return nil
61 | }
62 |
63 | return m
64 | }
65 |
66 | // MoveSlice moves the element s[i] to index j in s.
67 | func MoveSlice[S ~[]E, E any](s S, i, j int) {
68 | x := s[i]
69 | if i < j {
70 | copy(s[i:j], s[i+1:j+1])
71 | } else if i > j {
72 | copy(s[j+1:i+1], s[j:i])
73 | }
74 | s[j] = x
75 | }
76 |
77 | // ByteCountIEC converts a size in bytes to a human-readable string in IEC (binary) format.
78 | func ByteCountIEC(b int64) string {
79 | const unit = 1024
80 | if b < unit {
81 | return fmt.Sprintf("%d B", b)
82 | }
83 | div, exp := int64(unit), 0
84 | for n := b / unit; n >= unit; n /= unit {
85 | div *= unit
86 | exp++
87 | }
88 | return fmt.Sprintf("%.1f %ciB",
89 | float64(b)/float64(div), "KMGTPE"[exp])
90 | }
91 |
--------------------------------------------------------------------------------
/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 (
11 | DefaultAppFile = "app.json"
12 | LangFile = "lang.config"
13 | )
14 |
15 | type App struct {
16 | Lang string `json:"lang,omitempty"`
17 | Password string `json:"password,omitempty"`
18 | CheckUpdate bool `json:"checkUpdate"`
19 | Defaults DefaultValue `json:"defaults"`
20 | Sort []string `json:"sort,omitempty"`
21 | Position []int32 `json:"position,omitempty"`
22 | }
23 |
24 | type DefaultValue struct {
25 | Protocol string `json:"protocol,omitempty"`
26 | User string `json:"user,omitempty"`
27 | LogLevel string `json:"logLevel"`
28 | LogMaxDays int64 `json:"logMaxDays"`
29 | DNSServer string `json:"dnsServer,omitempty"`
30 | NatHoleSTUNServer string `json:"natHoleStunServer,omitempty"`
31 | ConnectServerLocalIP string `json:"connectServerLocalIP,omitempty"`
32 | TCPMux bool `json:"tcpMux"`
33 | TLSEnable bool `json:"tls"`
34 | ManualStart bool `json:"manualStart,omitempty"`
35 | LegacyFormat bool `json:"legacyFormat,omitempty"`
36 | }
37 |
38 | func (dv *DefaultValue) AsClientConfig() ClientCommon {
39 | return ClientCommon{
40 | ServerPort: consts.DefaultServerPort,
41 | Protocol: dv.Protocol,
42 | User: dv.User,
43 | LogLevel: dv.LogLevel,
44 | LogMaxDays: dv.LogMaxDays,
45 | DNSServer: dv.DNSServer,
46 | NatHoleSTUNServer: dv.NatHoleSTUNServer,
47 | ConnectServerLocalIP: dv.ConnectServerLocalIP,
48 | TCPMux: dv.TCPMux,
49 | TLSEnable: dv.TLSEnable,
50 | ManualStart: dv.ManualStart,
51 | LegacyFormat: dv.LegacyFormat,
52 | DisableCustomTLSFirstByte: true,
53 | }
54 | }
55 |
56 | func UnmarshalAppConf(path string, dst *App) (lang *string, err error) {
57 | b, err := os.ReadFile(LangFile)
58 | if err == nil {
59 | s := string(b)
60 | lang = &s
61 | }
62 | b, err = os.ReadFile(path)
63 | if err != nil {
64 | return
65 | }
66 | err = json.Unmarshal(b, dst)
67 | return
68 | }
69 |
70 | func (conf *App) Save(path string) error {
71 | b, err := json.MarshalIndent(conf, "", " ")
72 | if err != nil {
73 | return err
74 | }
75 | return os.WriteFile(path, b, 0666)
76 | }
77 |
--------------------------------------------------------------------------------
/.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 | strategy:
15 | matrix:
16 | architecture: [x64, x86, arm64]
17 | steps:
18 | - name: Checkout
19 | uses: actions/checkout@v4
20 |
21 | - name: Setup Go environment
22 | uses: actions/setup-go@v5
23 | with:
24 | go-version: '1.24'
25 |
26 | - name: Setup VS environment
27 | shell: powershell
28 | run: echo "$(vswhere.exe -latest -property installationPath)\VC\Auxiliary\Build" >> $env:GITHUB_PATH
29 |
30 | - name: Setup toolchain
31 | shell: cmd
32 | run: |
33 | curl "https://github.com/mstorsjo/llvm-mingw/releases/download/20250709/llvm-mingw-20250709-msvcrt-x86_64.zip" -o llvm-mingw-20250709-msvcrt-x86_64.zip -L
34 | tar -xf llvm-mingw-20250709-msvcrt-x86_64.zip
35 | echo %CD%\llvm-mingw-20250709-msvcrt-x86_64\bin>>%GITHUB_PATH%
36 | vcvarsall x64 && set WindowsSdkVerBinPath >> %GITHUB_ENV%
37 |
38 | - name: Add commit hash to version number
39 | shell: powershell
40 | if: github.event_name == 'pull_request'
41 | env:
42 | HEAD_SHA: ${{ github.event.pull_request.head.sha }}
43 | run: |
44 | $versionFile = ".\pkg\version\version.go"
45 | $rev = [UInt16]("0x" + $env:HEAD_SHA.Substring(0, 4))
46 | $version = (findstr /r "Number.*=.*[0-9.]*" $versionFile | Select-Object -First 1 | ConvertFrom-StringData).Get_Item("Number")
47 | $newVersion = $version.Substring(0, $version.Length - 1) + ".$rev" + '"'
48 | (Get-Content $versionFile).Replace($version, $newVersion) | Set-Content $versionFile
49 | echo "VERSION=$($newVersion.Replace('"', ''))" >> $env:GITHUB_ENV
50 |
51 | - name: Build
52 | shell: cmd
53 | run: build.bat ${{ matrix.architecture }}
54 |
55 | - name: Upload
56 | uses: actions/upload-artifact@v4
57 | if: github.event_name == 'pull_request'
58 | with:
59 | name: frpmgr-${{ env.VERSION }}-test-${{ matrix.architecture }}
60 | path: |
61 | bin/*.exe
62 | bin/*.zip
63 | retention-days: 7
64 |
65 | test:
66 | name: Go
67 | runs-on: windows-latest
68 | steps:
69 | - name: Checkout
70 | uses: actions/checkout@v4
71 |
72 | - name: Setup Go environment
73 | uses: actions/setup-go@v5
74 | with:
75 | go-version: '1.24'
76 |
77 | - name: Test
78 | run: go test -v ./...
79 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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_MODERN
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 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/koho/frpmgr
2 |
3 | go 1.24.0
4 |
5 | require (
6 | github.com/Microsoft/go-winio v0.6.2
7 | github.com/fatedier/frp v0.65.0
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/golang/snappy v0.0.4 // indirect
25 | github.com/gorilla/mux v1.8.1 // indirect
26 | github.com/hashicorp/yamux v0.1.1 // indirect
27 | github.com/inconshreveable/mousetrap v1.1.0 // indirect
28 | github.com/klauspost/cpuid/v2 v2.2.6 // indirect
29 | github.com/klauspost/reedsolomon v1.12.0 // indirect
30 | github.com/kr/text v0.2.0 // indirect
31 | github.com/pion/dtls/v2 v2.2.7 // indirect
32 | github.com/pion/logging v0.2.2 // indirect
33 | github.com/pion/stun/v2 v2.0.0 // indirect
34 | github.com/pion/transport/v2 v2.2.1 // indirect
35 | github.com/pion/transport/v3 v3.0.1 // indirect
36 | github.com/pires/go-proxyproto v0.7.0 // indirect
37 | github.com/pkg/errors v0.9.1 // indirect
38 | github.com/quic-go/quic-go v0.53.0 // indirect
39 | github.com/songgao/water v0.0.0-20200317203138-2b4b6d7c09d8 // indirect
40 | github.com/spf13/cobra v1.8.0 // indirect
41 | github.com/spf13/pflag v1.0.5 // indirect
42 | github.com/templexxx/cpu v0.1.1 // indirect
43 | github.com/templexxx/xorsimd v0.4.3 // indirect
44 | github.com/tjfoc/gmsm v1.4.1 // indirect
45 | github.com/vishvananda/netlink v1.3.0 // indirect
46 | github.com/vishvananda/netns v0.0.4 // indirect
47 | github.com/xtaci/kcp-go/v5 v5.6.13 // indirect
48 | go.uber.org/mock v0.5.0 // indirect
49 | golang.org/x/crypto v0.37.0 // indirect
50 | golang.org/x/mod v0.24.0 // indirect
51 | golang.org/x/net v0.39.0 // indirect
52 | golang.org/x/oauth2 v0.28.0 // indirect
53 | golang.org/x/sync v0.13.0 // indirect
54 | golang.org/x/time v0.5.0 // indirect
55 | golang.org/x/tools v0.31.0 // indirect
56 | golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
57 | golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
58 | gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect
59 | gopkg.in/yaml.v2 v2.4.0 // indirect
60 | k8s.io/apimachinery v0.28.8 // indirect
61 | k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect
62 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect
63 | sigs.k8s.io/yaml v1.3.0 // indirect
64 | )
65 |
66 | replace github.com/lxn/walk => github.com/koho/frpmgr v0.0.0-20250619085234-ed99ab60add0
67 |
--------------------------------------------------------------------------------
/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.LangFile)
46 | if err == nil {
47 | id := string(b)
48 | if _, ok := IDToName[id]; ok {
49 | return id
50 | }
51 | }
52 | b, err = os.ReadFile(config.DefaultAppFile)
53 | if err != nil {
54 | return ""
55 | }
56 | var s struct {
57 | Lang string `json:"lang"`
58 | }
59 | if err = json.Unmarshal(b, &s); err != nil {
60 | return ""
61 | }
62 | if _, ok := IDToName[s.Lang]; ok {
63 | return s.Lang
64 | }
65 | return ""
66 | }
67 |
68 | // lang returns the user preferred UI language.
69 | func lang() (tag language.Tag) {
70 | tag = language.English
71 | languages, err := windows.GetUserPreferredUILanguages(windows.MUI_LANGUAGE_NAME)
72 | if err != nil {
73 | return
74 | }
75 | if match := message.MatchLanguage(languages...); !match.IsRoot() {
76 | tag = match
77 | }
78 | return
79 | }
80 |
81 | // Sprintf is just a wrapper function of message printer.
82 | func Sprintf(key message.Reference, a ...interface{}) string {
83 | return printer.Sprintf(key, a...)
84 | }
85 |
86 | // SprintfColon adds a colon at the tail of a string.
87 | func SprintfColon(key message.Reference, a ...interface{}) string {
88 | return Sprintf(key, a...) + ":"
89 | }
90 |
91 | // SprintfEllipsis adds an ellipsis at the tail of a string.
92 | func SprintfEllipsis(key message.Reference, a ...interface{}) string {
93 | return Sprintf(key, a...) + "..."
94 | }
95 |
96 | // SprintfLSpace adds a space at the start of a string.
97 | func SprintfLSpace(key message.Reference, a ...interface{}) string {
98 | return " " + Sprintf(key, a...)
99 | }
100 |
101 | // SprintfRSpace adds a space at the end of a string.
102 | func SprintfRSpace(key message.Reference, a ...interface{}) string {
103 | return Sprintf(key, a...) + " "
104 | }
105 |
--------------------------------------------------------------------------------
/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/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")
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/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 |
--------------------------------------------------------------------------------
/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 | - [Windows SDK](https://developer.microsoft.com/en-us/windows/downloads/windows-sdk/)
40 | - [MinGW](https://github.com/mstorsjo/llvm-mingw)
41 | - [WiX Toolset](https://wixtoolset.org/) v3.14
42 |
43 | 安装完成后,您需要设置 `WindowsSdkVerBinPath` 环境变量,以指示构建脚本在哪里找到特定版本的 Windows SDK,例如 `set WindowsSdkVerBinPath=C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\`。您还需要将 MinGW 的 `bin` 目录添加到 `PATH` 环境变量中。
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 | 如果您想跳过构建安装包,可以在 `build` 命令中添加 `-p` 选项来获得一个便携软件:
56 |
57 | ```shell
58 | build.bat -p
59 | ```
60 |
61 | 在这种情况下,您只需安装 Go 和 MinGW 即可。
62 |
63 | ### 调试
64 |
65 | 如果您是首次构建项目,则需要编译资源:
66 |
67 | ```shell
68 | go generate
69 | ```
70 |
71 | 除非项目资源发生变化,否则无需再次执行该命令。
72 |
73 | 之后,即可直接运行该应用程序:
74 |
75 | ```shell
76 | go run ./cmd/frpmgr
77 | ```
78 |
79 | ## 赞助商
80 |
81 | > 我们非常感谢所有为项目发展而努力的用户、贡献者和赞助者。同时也感谢这些公司/组织为我们提供服务。
82 |
83 | 1. SignPath Foundation 为我们提供免费的代码签名!
84 |
85 |
86 |
87 |
88 |
89 |
90 | ## 代码签名政策
91 |
92 | 免费代码签名由 [SignPath.io](https://about.signpath.io/) 提供,证书由 [SignPath Foundation](https://signpath.org/) 提供。
93 |
94 | 团队角色:
95 |
96 | - 提交者和审阅者:[团队成员](https://github.com/koho/frpmgr/graphs/contributors)
97 | - 审批者:[所有者](https://github.com/koho)
98 |
99 | 请阅读我们的完整[隐私政策](#隐私政策)。
100 |
101 | ## 隐私政策
102 |
103 | 除非得到用户、安装或操作人员的许可,否则该程序不会将任何信息传输到其他联网系统。
104 |
105 | FRP 管理器集成了以下服务以实现附加功能,您可以随时在设置中启用或禁用这些服务:
106 |
107 | - [api.github.com](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement)(检查程序更新)
108 |
109 | ## 捐助
110 |
111 | 如果本项目对您有帮助,请考虑通过以下方式支持其开发:
112 |
113 | - [**微信**](/docs/donate-wechat.jpg)
114 |
--------------------------------------------------------------------------------
/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 |
120 | // QueryStartInfo returns the start type and process id of the given service.
121 | func QueryStartInfo(configPath string) (uint32, uint32, error) {
122 | m, err := serviceManager()
123 | if err != nil {
124 | return 0, 0, err
125 | }
126 | serviceName := ServiceNameOfClient(configPath)
127 | service, err := m.OpenService(serviceName)
128 | if err != nil {
129 | return 0, 0, err
130 | }
131 | defer service.Close()
132 | cfg, err := service.Config()
133 | if err != nil {
134 | return 0, 0, err
135 | }
136 | var pid uint32
137 | if status, err := service.Query(); err == nil {
138 | pid = status.ProcessId
139 | }
140 | return cfg.StartType, pid, nil
141 | }
142 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | glog "github.com/fatedier/golib/log"
14 |
15 | "github.com/koho/frpmgr/pkg/consts"
16 | )
17 |
18 | type FrpClientService struct {
19 | svr *client.Service
20 | file string
21 | cfg *v1.ClientCommonConfig
22 | done chan struct{}
23 | statusExporter client.StatusExporter
24 | logger *glog.RotateFileWriter
25 | }
26 |
27 | func NewFrpClientService(cfgFile string) (*FrpClientService, error) {
28 | cfg, pxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(cfgFile, false)
29 | if err != nil {
30 | return nil, err
31 | }
32 | svr, err := client.NewService(client.ServiceOptions{
33 | Common: cfg,
34 | ProxyCfgs: pxyCfgs,
35 | VisitorCfgs: visitorCfgs,
36 | ConfigFilePath: cfgFile,
37 | })
38 | if err != nil {
39 | return nil, err
40 | }
41 | logger := initLogger(cfg.Log.To, cfg.Log.Level, int(cfg.Log.MaxDays))
42 | return &FrpClientService{
43 | svr: svr,
44 | file: cfgFile,
45 | cfg: cfg,
46 | done: make(chan struct{}),
47 | statusExporter: svr.StatusExporter(),
48 | logger: logger,
49 | }, nil
50 | }
51 |
52 | // Run starts frp client service in blocking mode.
53 | func (s *FrpClientService) Run() {
54 | defer close(s.done)
55 | if s.file != "" {
56 | log.Infof("start frpc service for config file [%s]", s.file)
57 | defer log.Infof("frpc service for config file [%s] stopped", s.file)
58 | }
59 |
60 | // There's no guarantee that this function will return after a close call.
61 | // So we can't wait for the Run function to finish.
62 | if err := s.svr.Run(context.Background()); err != nil {
63 | log.Errorf("run service error: %v", err)
64 | }
65 | }
66 |
67 | // Stop closes all frp connections.
68 | func (s *FrpClientService) Stop(wait bool) {
69 | // Close client service.
70 | if wait {
71 | s.svr.GracefulClose(500 * time.Millisecond)
72 | } else {
73 | s.svr.Close()
74 | }
75 | }
76 |
77 | // Reload creates or updates or removes proxies of frpc.
78 | func (s *FrpClientService) Reload() error {
79 | _, pxyCfgs, visitorCfgs, _, err := config.LoadClientConfig(s.file, false)
80 | if err != nil {
81 | return err
82 | }
83 | return s.svr.UpdateAllConfigurer(pxyCfgs, visitorCfgs)
84 | }
85 |
86 | func (s *FrpClientService) Done() <-chan struct{} {
87 | return s.done
88 | }
89 |
90 | func (s *FrpClientService) GetProxyStatus(name string) (status *proxy.WorkingStatus, ok bool) {
91 | proxyName := name
92 | if s.cfg.User != "" {
93 | proxyName = s.cfg.User + "." + name
94 | }
95 | status, ok = s.statusExporter.GetProxyStatus(proxyName)
96 | if ok {
97 | status.Name = name
98 | if status.Err == "" {
99 | if status.Type == consts.ProxyTypeTCP || status.Type == consts.ProxyTypeUDP {
100 | status.RemoteAddr = s.cfg.ServerAddr + status.RemoteAddr
101 | }
102 | } else {
103 | status.RemoteAddr = ""
104 | }
105 | }
106 | return
107 | }
108 |
109 | func initLogger(logPath string, levelStr string, maxDays int) *glog.RotateFileWriter {
110 | var options []glog.Option
111 | writer := glog.NewRotateFileWriter(glog.RotateFileConfig{
112 | FileName: logPath,
113 | Mode: glog.RotateFileModeDaily,
114 | MaxDays: maxDays,
115 | })
116 | writer.Init()
117 | options = append(options, glog.WithOutput(writer))
118 | level, err := glog.ParseLevel(levelStr)
119 | if err != nil {
120 | level = glog.InfoLevel
121 | }
122 | options = append(options, glog.WithLevel(level))
123 | log.Logger = log.Logger.WithOptions(options...)
124 | return writer
125 | }
126 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/services/service.go:
--------------------------------------------------------------------------------
1 | package services
2 |
3 | import (
4 | "crypto/md5"
5 | "fmt"
6 | "os"
7 | "path/filepath"
8 | "syscall"
9 | "time"
10 |
11 | "github.com/Microsoft/go-winio"
12 | "github.com/fatedier/frp/pkg/util/log"
13 | "golang.org/x/sys/windows"
14 | "golang.org/x/sys/windows/svc"
15 |
16 | "github.com/koho/frpmgr/pkg/config"
17 | "github.com/koho/frpmgr/pkg/ipc"
18 | "github.com/koho/frpmgr/pkg/util"
19 | )
20 |
21 | func ServiceNameOfClient(configPath string) string {
22 | return fmt.Sprintf("frpmgr_%x", md5.Sum([]byte(util.FileNameWithoutExt(configPath))))
23 | }
24 |
25 | func DisplayNameOfClient(name string) string {
26 | return "FRP Manager: " + name
27 | }
28 |
29 | type frpService struct {
30 | configPath string
31 | }
32 |
33 | func (service *frpService) Execute(args []string, r <-chan svc.ChangeRequest, changes chan<- svc.Status) (svcSpecificEC bool, exitCode uint32) {
34 | path, err := os.Executable()
35 | if err != nil {
36 | return
37 | }
38 | if err = os.Chdir(filepath.Dir(path)); err != nil {
39 | return
40 | }
41 | changes <- svc.Status{State: svc.StartPending}
42 |
43 | defer func() {
44 | changes <- svc.Status{State: svc.StopPending}
45 | }()
46 |
47 | cc, err := config.UnmarshalClientConf(service.configPath)
48 | if err != nil {
49 | return
50 | }
51 | var expired <-chan time.Time
52 | t, err := config.Expiry(service.configPath, cc.AutoDelete)
53 | switch err {
54 | case nil:
55 | if t <= 0 {
56 | deleteFrpFiles(args[0], service.configPath, cc.LogFile)
57 | return
58 | }
59 | expired = time.After(t)
60 | case os.ErrNoDeadline:
61 | break
62 | default:
63 | return
64 | }
65 |
66 | svr, err := NewFrpClientService(service.configPath)
67 | if err != nil {
68 | return
69 | }
70 |
71 | is, err := ipc.NewServer(args[0], svr)
72 | if err != nil {
73 | return
74 | }
75 | defer is.Close()
76 |
77 | go svr.Run()
78 | go is.Run()
79 |
80 | changes <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown | svc.AcceptParamChange}
81 |
82 | for {
83 | select {
84 | case c := <-r:
85 | switch c.Cmd {
86 | case svc.Stop, svc.Shutdown:
87 | svr.Stop(false)
88 | if code := shutdownReason(path); code > 0 {
89 | return false, code
90 | }
91 | return
92 | case svc.ParamChange:
93 | // Reload service
94 | if err = svr.Reload(); err != nil {
95 | log.Errorf("reload frp config error: %v", err)
96 | }
97 | case svc.Interrogate:
98 | changes <- c.CurrentStatus
99 | default:
100 | }
101 | case <-svr.Done():
102 | return
103 | case <-expired:
104 | svr.Stop(false)
105 | svr.logger.Close()
106 | deleteFrpFiles(args[0], service.configPath, cc.LogFile)
107 | return
108 | }
109 | }
110 | }
111 |
112 | // Run executes frp service in background service process.
113 | func Run(configPath string) error {
114 | serviceName := ServiceNameOfClient(configPath)
115 | return svc.Run(serviceName, &frpService{configPath})
116 | }
117 |
118 | // ReloadService sends a reload event to the frp service
119 | // which triggers hot-reloading of frp configuration.
120 | func ReloadService(configPath string) error {
121 | m, err := serviceManager()
122 | if err != nil {
123 | return err
124 | }
125 |
126 | svcName := ServiceNameOfClient(configPath)
127 | service, err := m.OpenService(svcName)
128 | if err != nil {
129 | return err
130 | }
131 | defer service.Close()
132 | _, err = service.Control(svc.ParamChange)
133 | return err
134 | }
135 |
136 | func shutdownReason(path string) uint32 {
137 | f, err := os.Open(path)
138 | if err != nil {
139 | return 0
140 | }
141 | fileID, err := winio.GetFileID(f)
142 | f.Close()
143 | if err != nil {
144 | return 0
145 | }
146 | name, err := syscall.UTF16PtrFromString(fmt.Sprintf("Global\\%x%x", fileID.VolumeSerialNumber, fileID.FileID))
147 | if err != nil {
148 | return 0
149 | }
150 | if h, err := windows.OpenEvent(windows.READ_CONTROL, false, name); err == nil {
151 | windows.CloseHandle(h)
152 | return uint32(windows.ERROR_FAIL_NOACTION_REBOOT)
153 | }
154 | return 0
155 | }
156 |
--------------------------------------------------------------------------------
/installer/build.bat:
--------------------------------------------------------------------------------
1 | @echo off
2 | setlocal enabledelayedexpansion
3 | set VERSION=%~1
4 | set ARCH=%~2
5 | set STEP="%~3"
6 | set BUILDDIR=%~dp0
7 | cd /d %BUILDDIR% || exit /b 1
8 | set TARGET_x64=x86_64
9 | set TARGET_x86=i686
10 | set TARGET_arm64=aarch64
11 |
12 | if "%VERSION%" == "" (
13 | echo ERROR: no version provided.
14 | exit /b 1
15 | )
16 |
17 | if "%ARCH%" == "" (
18 | echo ERROR: no architecture provided.
19 | exit /b 1
20 | )
21 |
22 | if not defined TARGET_%ARCH% (
23 | echo ERROR: unsupported architecture.
24 | exit /b 1
25 | )
26 |
27 | :build
28 | if not exist build md build
29 | set PLAT_DIR=build\%ARCH%
30 | set SETUP_FILENAME=frpmgr-%VERSION%-setup-%ARCH%.exe
31 | if %STEP% == "dist" goto :dist
32 | set CC=!TARGET_%ARCH%!-w64-mingw32-gcc
33 | set WINDRES=!TARGET_%ARCH%!-w64-mingw32-windres
34 | if not exist %PLAT_DIR% md %PLAT_DIR%
35 | set MSI_FILE=%PLAT_DIR%\frpmgr.msi
36 | if %STEP:"actions"=""% == "" call :build_actions || goto :error
37 | if %STEP:"msi"=""% == "" call :build_msi || goto :error
38 | if %STEP:"setup"=""% == "" call :build_setup || goto :error
39 | if %STEP% == "" goto :dist
40 |
41 | :success
42 | exit /b 0
43 |
44 | :build_actions
45 | %WINDRES% -DVERSION_ARRAY=%VERSION:.=,% -DVERSION_STR=%VERSION% -o %PLAT_DIR%\actions.res.obj -i actions\version.rc -O coff -c 65001 || exit /b 1
46 | set CFLAGS=-O3 -Wall -std=gnu11 -DWINVER=0x0602 -D_WIN32_WINNT=0x0602 -municode -DUNICODE -D_UNICODE -DNDEBUG
47 | set LDFLAGS=-shared -s -Wl,--kill-at -Wl,--major-os-version=6 -Wl,--minor-os-version=2 -Wl,--major-subsystem-version=6 -Wl,--minor-subsystem-version=2 -Wl,--tsaware -Wl,--dynamicbase -Wl,--nxcompat -Wl,--export-all-symbols
48 | set LDLIBS=-lmsi -lole32 -lshlwapi -lshell32 -ladvapi32
49 | %CC% %CFLAGS% %LDFLAGS% -o %PLAT_DIR%\actions.dll actions\actions.c %PLAT_DIR%\actions.res.obj %LDLIBS% || exit /b 1
50 | goto :eof
51 |
52 | :build_msi
53 | if not defined WIX (
54 | echo ERROR: WIX was not found.
55 | exit /b 1
56 | )
57 | set WIX_CANDLE_FLAGS=-dVERSION=%VERSION%
58 | set WIX_LIGHT_FLAGS=-ext "%WIX%bin\WixUtilExtension.dll" -ext "%WIX%bin\WixUIExtension.dll" -sval
59 | set WIX_OBJ=%PLAT_DIR%\frpmgr.wixobj
60 | "%WIX%bin\candle" %WIX_CANDLE_FLAGS% -out %WIX_OBJ% -arch %ARCH% msi\frpmgr.wxs || exit /b 1
61 | "%WIX%bin\light" %WIX_LIGHT_FLAGS% -cultures:en-US -loc msi\en-US.wxl -out %MSI_FILE% %WIX_OBJ% || exit /b 1
62 | for %%l in (zh-CN zh-TW ja-JP ko-KR es-ES) do (
63 | set WIX_LANG_MSI=%MSI_FILE:~0,-4%_%%l.msi
64 | "%WIX%bin\light" %WIX_LIGHT_FLAGS% -cultures:%%l -loc msi\%%l.wxl -out !WIX_LANG_MSI! %WIX_OBJ% || exit /b 1
65 | for /f "tokens=3 delims=><" %%a in ('findstr /r "Id.*=.*Language" msi\%%l.wxl') do set LANG_CODE=%%a
66 | "%WindowsSdkVerBinPath%x86\MsiTran" -g %MSI_FILE% !WIX_LANG_MSI! %PLAT_DIR%\!LANG_CODE! || exit /b 1
67 | "%WindowsSdkVerBinPath%x86\MsiDb" -d %MSI_FILE% -r %PLAT_DIR%\!LANG_CODE! || exit /b 1
68 | )
69 | goto :eof
70 |
71 | :build_setup
72 | %WINDRES% -DFILENAME=%SETUP_FILENAME% -DVERSION_ARRAY=%VERSION:.=,% -DVERSION_STR=%VERSION% -DMSI_FILE=%MSI_FILE:\=\\% -o %PLAT_DIR%\setup.res.obj -i setup\resource.rc -O coff -c 65001 || exit /b 1
73 | set ARCH_LINE=-1
74 | for /f "tokens=1 delims=:" %%a in ('findstr /n /r ".*=.*\"%ARCH%\"" msi\frpmgr.wxs') do set ARCH_LINE=%%a
75 | if %ARCH_LINE% lss 0 (
76 | echo ERROR: unsupported architecture.
77 | exit /b 1
78 | )
79 | for /f "tokens=1,5 delims=: " %%a in ('findstr /n /r "UpgradeCode.*=.*\"[0-9a-fA-F-]*\"" msi\frpmgr.wxs') do (
80 | if %%a gtr %ARCH_LINE% if not defined UPGRADE_CODE set UPGRADE_CODE=%%b
81 | )
82 | if not defined UPGRADE_CODE (
83 | echo ERROR: UpgradeCode was not found.
84 | exit /b 1
85 | )
86 | set CFLAGS=-O3 -Wall -std=gnu11 -DWINVER=0x0602 -D_WIN32_WINNT=0x0602 -municode -DUNICODE -D_UNICODE -DNDEBUG -DUPGRADE_CODE=L\"{%UPGRADE_CODE%}\" -DVERSION=L\"%VERSION%\"
87 | set LDFLAGS=-s -Wl,--major-os-version=6 -Wl,--minor-os-version=2 -Wl,--major-subsystem-version=6 -Wl,--minor-subsystem-version=2 -Wl,--tsaware -Wl,--dynamicbase -Wl,--nxcompat -mwindows
88 | set LDLIBS=-lmsi -lole32 -lshlwapi -ladvapi32 -luser32 -lcomctl32
89 | %CC% %CFLAGS% %LDFLAGS% -o %PLAT_DIR%\setup.exe setup\setup.c %PLAT_DIR%\setup.res.obj %LDLIBS% || exit /b 1
90 | goto :eof
91 |
92 | :dist
93 | echo [+] Creating %ARCH% archives
94 | tar -ac -C ..\bin\%ARCH% -f ..\bin\frpmgr-%VERSION%-%ARCH%.zip frpmgr.exe || goto :error
95 | echo [+] Creating %ARCH% installer
96 | copy %PLAT_DIR%\setup.exe ..\bin\%SETUP_FILENAME% /y
97 |
98 | :error
99 | exit /b %errorlevel%
100 |
--------------------------------------------------------------------------------
/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/net.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "io"
8 | "mime"
9 | "net/http"
10 | "path"
11 | "syscall"
12 | "time"
13 | "unsafe"
14 |
15 | "golang.org/x/sys/windows"
16 | )
17 |
18 | var (
19 | modIPHelp = syscall.NewLazyDLL("iphlpapi.dll")
20 | procGetExtendedTcpTable = modIPHelp.NewProc("GetExtendedTcpTable")
21 | procGetExtendedUdpTable = modIPHelp.NewProc("GetExtendedUdpTable")
22 | )
23 |
24 | // DownloadFile downloads a file from the given url
25 | func DownloadFile(ctx context.Context, url string) (filename, mediaType string, data []byte, err error) {
26 | req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
27 | if err != nil {
28 | return
29 | }
30 | client := http.Client{Timeout: 10 * time.Second}
31 | resp, err := client.Do(req)
32 | if err != nil {
33 | return
34 | }
35 | defer resp.Body.Close()
36 |
37 | // Check server response
38 | if resp.StatusCode != http.StatusOK {
39 | err = fmt.Errorf("bad status: %s", resp.Status)
40 | return
41 | }
42 | // Use the filename in header
43 | if cd := resp.Header.Get("Content-Disposition"); cd != "" {
44 | if _, params, err := mime.ParseMediaType(cd); err == nil {
45 | filename = params["filename"]
46 | }
47 | }
48 | // Use the base filename part of the URL
49 | if filename == "" {
50 | filename = path.Base(resp.Request.URL.Path)
51 | }
52 | if mediaType, _, err = mime.ParseMediaType(resp.Header.Get("Content-Type")); err == nil {
53 | data, err = io.ReadAll(resp.Body)
54 | return filename, mediaType, data, err
55 | } else {
56 | return "", "", nil, err
57 | }
58 | }
59 |
60 | //nolint:unused
61 | type mibTCPRowOwnerPid struct {
62 | dwState uint32
63 | dwLocalAddr uint32
64 | dwLocalPort uint32
65 | dwRemoteAddr uint32
66 | dwRemotePort uint32
67 | dwOwningPid uint32
68 | }
69 |
70 | //nolint:unused
71 | type mibTCP6RowOwnerPid struct {
72 | ucLocalAddr [16]byte
73 | dwLocalScopeId uint32
74 | dwLocalPort uint32
75 | ucRemoteAddr [16]byte
76 | dwRemoteScopeId uint32
77 | dwRemotePort uint32
78 | dwState uint32
79 | dwOwningPid uint32
80 | }
81 |
82 | //nolint:unused
83 | type mibUDPRowOwnerPid struct {
84 | dwLocalAddr uint32
85 | dwLocalPort uint32
86 | dwOwningPid uint32
87 | }
88 |
89 | //nolint:unused
90 | type mibUDP6RowOwnerPid struct {
91 | ucLocalAddr [16]byte
92 | dwLocalScopeId uint32
93 | dwLocalPort uint32
94 | dwOwningPid uint32
95 | }
96 |
97 | type mibTableOwnerPid[T any] struct {
98 | dwNumEntries uint32
99 | table [1]T
100 | }
101 |
102 | // countConnections returns the number of IPv4 and IPv6 connections that match the given filter.
103 | // - https://learn.microsoft.com/en-us/windows/win32/api/iphlpapi/nf-iphlpapi-getextendedtcptable
104 | // - https://learn.microsoft.com/en-us/windows/win32/api/iphlpapi/nf-iphlpapi-getextendedudptable
105 | func countConnections[R4, R6 any](proc *syscall.LazyProc, tableClass uintptr, filter4 func(R4) bool, filter6 func(R6) bool) (count int) {
106 | var size uint32
107 | var buf []byte
108 | getTable := func(af uintptr) bool {
109 | for {
110 | var pTable *byte
111 | if len(buf) > 0 {
112 | pTable = &buf[0]
113 | }
114 | ret, _, _ := proc.Call(uintptr(unsafe.Pointer(pTable)), uintptr(unsafe.Pointer(&size)), 0, af, tableClass, 0)
115 | if ret != 0 {
116 | if errors.Is(syscall.Errno(ret), syscall.ERROR_INSUFFICIENT_BUFFER) {
117 | buf = make([]byte, int(size))
118 | continue
119 | }
120 | return false
121 | }
122 | return true
123 | }
124 | }
125 | if getTable(windows.AF_INET) {
126 | table := (*mibTableOwnerPid[R4])(unsafe.Pointer(&buf[0]))
127 | for _, conn := range unsafe.Slice(&table.table[0], table.dwNumEntries) {
128 | if filter4(conn) {
129 | count++
130 | }
131 | }
132 | }
133 | if getTable(windows.AF_INET6) {
134 | table := (*mibTableOwnerPid[R6])(unsafe.Pointer(&buf[0]))
135 | for _, conn := range unsafe.Slice(&table.table[0], table.dwNumEntries) {
136 | if filter6(conn) {
137 | count++
138 | }
139 | }
140 | }
141 | return
142 | }
143 |
144 | // CountTCPConnections returns the number of connected TCP endpoints for a given process.
145 | func CountTCPConnections(pid uint32) int {
146 | return countConnections(procGetExtendedTcpTable, 4, func(r4 mibTCPRowOwnerPid) bool {
147 | return r4.dwOwningPid == pid
148 | }, func(r6 mibTCP6RowOwnerPid) bool {
149 | return r6.dwOwningPid == pid
150 | })
151 | }
152 |
153 | // CountUDPConnections returns the number of UDP endpoints for a given process.
154 | func CountUDPConnections(pid uint32) int {
155 | return countConnections(procGetExtendedUdpTable, 1, func(r4 mibUDPRowOwnerPid) bool {
156 | return r4.dwOwningPid == pid
157 | }, func(r6 mibUDP6RowOwnerPid) bool {
158 | return r6.dwOwningPid == pid
159 | })
160 | }
161 |
--------------------------------------------------------------------------------
/ui/properties.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "os"
5 | "strconv"
6 | "strings"
7 | "syscall"
8 | "time"
9 |
10 | "github.com/lxn/walk"
11 | . "github.com/lxn/walk/declarative"
12 | "golang.org/x/sys/windows"
13 |
14 | "github.com/koho/frpmgr/i18n"
15 | "github.com/koho/frpmgr/pkg/consts"
16 | "github.com/koho/frpmgr/pkg/res"
17 | "github.com/koho/frpmgr/pkg/util"
18 | "github.com/koho/frpmgr/services"
19 | )
20 |
21 | type PropertiesDialog struct {
22 | *walk.Dialog
23 |
24 | table *walk.TableView
25 | conf *Conf
26 | }
27 |
28 | func NewPropertiesDialog(conf *Conf) *PropertiesDialog {
29 | return &PropertiesDialog{conf: conf}
30 | }
31 |
32 | func (pd *PropertiesDialog) logFileStat() (count int, size int64) {
33 | if logs, _, err := util.FindLogFiles(pd.conf.Data.LogFile); err == nil {
34 | for _, logFile := range logs {
35 | if fileInfo, err := os.Stat(logFile); err == nil {
36 | count++
37 | size += fileInfo.Size()
38 | }
39 | }
40 | }
41 | return
42 | }
43 |
44 | func (pd *PropertiesDialog) Run(owner walk.Form) (int, error) {
45 | logFileCount, logFileSize := pd.logFileStat()
46 | logSizeDesc := util.ByteCountIEC(logFileSize)
47 |
48 | var startTypeDesc string
49 | startType, pid, _ := services.QueryStartInfo(pd.conf.Path)
50 | switch startType {
51 | case windows.SERVICE_AUTO_START:
52 | startTypeDesc = i18n.Sprintf("Auto")
53 | case windows.SERVICE_DEMAND_START:
54 | startTypeDesc = i18n.Sprintf("Manual")
55 | default:
56 | startTypeDesc = i18n.Sprintf("None")
57 | }
58 | items := []*ListItem{
59 | {Title: i18n.Sprintf("Name"), Value: pd.conf.Name()},
60 | {Title: i18n.Sprintf("Identifier"), Value: util.FileNameWithoutExt(pd.conf.Path)},
61 | {Title: i18n.Sprintf("Service Name"), Value: services.ServiceNameOfClient(pd.conf.Path)},
62 | {Title: i18n.Sprintf("File Format"), Value: strings.ToUpper(pd.conf.Data.Ext()[1:])},
63 | {Title: i18n.Sprintf("Server Address"), Value: pd.conf.Data.ServerAddress},
64 | {Title: i18n.Sprintf("Server Port"), Value: strconv.Itoa(pd.conf.Data.ServerPort)},
65 | {Title: i18n.Sprintf("Protocol"), Value: util.GetOrElse(pd.conf.Data.Protocol, consts.ProtoTCP)},
66 | {Title: i18n.Sprintf("Number of Proxies"), Value: strconv.Itoa(len(pd.conf.Data.Proxies))},
67 | {Title: i18n.Sprintf("Start Type"), Value: startTypeDesc},
68 | {Title: i18n.Sprintf("Log"), Value: i18n.Sprintf("%d Files, %s", logFileCount, logSizeDesc)},
69 | }
70 | if pid > 0 {
71 | items = append(items, &ListItem{
72 | Title: i18n.Sprintf("Number of TCP Connections"),
73 | Value: strconv.Itoa(util.CountTCPConnections(pid)),
74 | }, &ListItem{
75 | Title: i18n.Sprintf("Number of UDP Connections"),
76 | Value: strconv.Itoa(util.CountUDPConnections(pid)),
77 | })
78 | if process, err := syscall.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, pid); err == nil {
79 | var creationTime, unusedTime syscall.Filetime
80 | if err = syscall.GetProcessTimes(process, &creationTime, &unusedTime, &unusedTime, &unusedTime); err == nil {
81 | items = append(items, &ListItem{
82 | Title: i18n.Sprintf("Started"),
83 | Value: time.Unix(0, creationTime.Nanoseconds()).Format(time.DateTime),
84 | })
85 | }
86 | syscall.CloseHandle(process)
87 | }
88 | }
89 | if info, err := os.Stat(pd.conf.Path); err == nil {
90 | created := time.Unix(0, info.Sys().(*syscall.Win32FileAttributeData).CreationTime.Nanoseconds())
91 | modified := info.ModTime()
92 | items = append(items, &ListItem{
93 | Title: i18n.Sprintf("Created"),
94 | Value: created.Format(time.DateTime),
95 | }, &ListItem{
96 | Title: i18n.Sprintf("Modified"),
97 | Value: modified.Format(time.DateTime),
98 | })
99 | }
100 | dlg := NewBasicDialog(&pd.Dialog, i18n.Sprintf("%s Properties", pd.conf.Name()),
101 | loadIcon(res.IconFile, 32),
102 | DataBinder{}, nil,
103 | TableView{
104 | AssignTo: &pd.table,
105 | Name: "properties",
106 | Columns: []TableViewColumn{
107 | {Title: i18n.Sprintf("Item"), DataMember: "Title"},
108 | {Title: i18n.Sprintf("Value"), DataMember: "Value", Width: 180},
109 | },
110 | ColumnsOrderable: false,
111 | Model: NewNonSortedModel(items),
112 | ContextMenuItems: []MenuItem{
113 | Action{
114 | Text: i18n.Sprintf("Copy Value"),
115 | Enabled: Bind("properties.CurrentIndex >= 0"),
116 | Visible: Bind("properties.CurrentIndex >= 0"),
117 | OnTriggered: func() {
118 | if idx := pd.table.CurrentIndex(); idx >= 0 && idx < len(items) {
119 | walk.Clipboard().SetText(items[idx].Value)
120 | }
121 | },
122 | },
123 | },
124 | },
125 | )
126 | dlg.MinSize = Size{Width: 420, Height: 350}
127 | if err := dlg.Create(owner); err != nil {
128 | return 0, err
129 | }
130 | pd.table.BoundsChanged().Once(func() {
131 | pd.table.FitColumn(0, 140)
132 | })
133 | return pd.Dialog.Run(), nil
134 | }
135 |
--------------------------------------------------------------------------------
/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 | if appConf.CheckUpdate {
110 | // Check update at launch
111 | ap.checkUpdate(false)
112 | }
113 | }
114 |
115 | func (ap *AboutPage) checkUpdate(showErr bool) {
116 | ap.viewModel.Checking = true
117 | ap.db.Reset()
118 | go func() {
119 | var body []byte
120 | resp, err := http.Get(res.UpdateURL)
121 | if err != nil {
122 | goto Fin
123 | }
124 | defer resp.Body.Close()
125 | if body, err = io.ReadAll(resp.Body); err != nil {
126 | goto Fin
127 | }
128 | ap.viewModel.GithubRelease = GithubRelease{}
129 | err = json.Unmarshal(body, &ap.viewModel.GithubRelease)
130 | Fin:
131 | ap.Synchronize(func() {
132 | ap.viewModel.Checking = false
133 | defer ap.db.Reset()
134 | if err != nil || resp.StatusCode != http.StatusOK {
135 | if showErr {
136 | showErrorMessage(ap.Form(), "", i18n.Sprintf("An error occurred while checking for a software update."))
137 | }
138 | return
139 | }
140 | if ap.viewModel.TagName != "" && ap.viewModel.TagName[1:] != version.Number {
141 | ap.viewModel.NewVersion = true
142 | } else {
143 | ap.viewModel.NewVersion = false
144 | if showErr {
145 | showInfoMessage(ap.Form(), "", i18n.Sprintf("There are currently no updates available."))
146 | }
147 | }
148 | })
149 | }()
150 | }
151 |
--------------------------------------------------------------------------------
/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 | IconSettings = Icon{"shell32", -153}
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 |
--------------------------------------------------------------------------------
/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/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 | pt.owner.Synchronize(func() {
116 | if pt.ctx.Err() != nil {
117 | return
118 | }
119 | for i, item := range pt.model.items {
120 | if item.Disabled {
121 | continue
122 | }
123 | var statusInfo ProxyStatusInfo
124 | if m, ok := stat[item.Proxy]; ok {
125 | state, _ := proxyPhaseToProxyState(m.Status)
126 | statusInfo = ProxyStatusInfo{
127 | State: state,
128 | Error: m.Err,
129 | StateSource: m.Name,
130 | RemoteAddr: m.RemoteAddr,
131 | }
132 | }
133 | if item.ProxyStatusInfo != statusInfo {
134 | item.ProxyStatusInfo = statusInfo
135 | item.UpdateRemotePort()
136 | pt.model.PublishRowChanged(i)
137 | if pt.refreshTimer != nil {
138 | pt.refreshTimer.Stop()
139 | pt.refreshTimer = nil
140 | }
141 | }
142 | }
143 | })
144 | }
145 |
146 | func (pt *ProxyTracker) buildCache() {
147 | pt.Lock()
148 | defer pt.Unlock()
149 | clear(pt.cache)
150 | for _, item := range pt.model.items {
151 | for _, name := range item.GetAlias() {
152 | pt.cache[name] = item.Proxy
153 | }
154 | }
155 | }
156 |
157 | func (pt *ProxyTracker) Close() {
158 | pt.model.RowsInserted().Detach(pt.rowsInsertedHandle)
159 | pt.model.BeforeRemove().Detach(pt.beforeRemoveHandle)
160 | pt.model.RowEdited().Detach(pt.rowEditedHandle)
161 | pt.model.RowRenamed().Detach(pt.rowRenamedHandle)
162 | pt.cancel()
163 | if pt.refreshTimer != nil {
164 | pt.refreshTimer.Stop()
165 | pt.refreshTimer = nil
166 | }
167 | }
168 |
169 | func proxyPhaseToProxyState(phase string) (consts.ProxyState, int) {
170 | switch phase {
171 | case proxy.ProxyPhaseRunning:
172 | return consts.ProxyStateRunning, 0
173 | case proxy.ProxyPhaseStartErr, proxy.ProxyPhaseCheckFailed, proxy.ProxyPhaseClosed:
174 | return consts.ProxyStateError, 2
175 | default:
176 | return consts.ProxyStateUnknown, 1
177 | }
178 | }
179 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | - [Windows SDK](https://developer.microsoft.com/en-us/windows/downloads/windows-sdk/)
40 | - [MinGW](https://github.com/mstorsjo/llvm-mingw)
41 | - [WiX Toolset](https://wixtoolset.org/) v3.14
42 |
43 | Once installed, the `WindowsSdkVerBinPath` environment variable should be set to tell build script where to find the specific version of Windows SDK, e.g., `set WindowsSdkVerBinPath=C:\Program Files (x86)\Windows Kits\10\bin\10.0.26100.0\`. You should also add the `bin` directory of MinGW to the `PATH` environment variable.
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 | You can also skip building the installation package and get a portable application by passing the `-p` option to the `build` command:
56 |
57 | ```shell
58 | build.bat -p
59 | ```
60 |
61 | In this case, you only need to install Go and MinGW.
62 |
63 | ### Debugging
64 |
65 | If you're building the project for the first time, you need to compile resources:
66 |
67 | ```shell
68 | go generate
69 | ```
70 |
71 | The command does not need to be executed again unless the project's resources change.
72 |
73 | After that, the application can be run directly:
74 |
75 | ```shell
76 | go run ./cmd/frpmgr
77 | ```
78 |
79 | ## Sponsors
80 |
81 | > We are really thankful for all of our users, contributors, and sponsors that has been keeping this project alive and well. We are also giving our gratitude for these company/organization for providing their service for us.
82 |
83 | 1. SignPath Foundation for providing us free code signing!
84 |
85 |
86 |
87 |
88 |
89 |
90 | ## Code Signing Policy
91 |
92 | Free code signing provided by [SignPath.io](https://about.signpath.io/), certificate by [SignPath Foundation](https://signpath.org/).
93 |
94 | Team roles:
95 |
96 | - Committers and reviewers: [Members team](https://github.com/koho/frpmgr/graphs/contributors)
97 | - Approvers: [Owners](https://github.com/koho)
98 |
99 | Read our full [Privacy Policy](#privacy-policy).
100 |
101 | ## Privacy Policy
102 |
103 | This program will not transfer any information to other networked systems unless specifically requested by the user or the person installing or operating it.
104 |
105 | FRP Manager has integrated the following services for additional functions, which can be enabled or disabled at any time in the settings:
106 |
107 | - [api.github.com](https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement) (Check for program updates)
108 |
109 | ## Donation
110 |
111 | If this project is useful to you, consider supporting its development in one of the following ways:
112 |
113 | - [**WeChat**](/docs/donate-wechat.jpg)
114 |
--------------------------------------------------------------------------------
/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/i18n"
13 | "github.com/koho/frpmgr/pkg/config"
14 | "github.com/koho/frpmgr/pkg/consts"
15 | "github.com/koho/frpmgr/pkg/util"
16 | "github.com/koho/frpmgr/services"
17 | )
18 |
19 | // The flag controls the running state of service.
20 | type runFlag int
21 |
22 | const (
23 | runFlagAuto runFlag = iota
24 | runFlagForceStart
25 | runFlagReload
26 | )
27 |
28 | // Conf contains all data of a config
29 | type Conf struct {
30 | // Path of the config file
31 | Path string
32 | // State of service
33 | State consts.ConfigState
34 | Data *config.ClientConfig
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.ClientConfig) *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.LogFile); 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.LogFile = filepath.ToSlash(logPath)
87 | return conf.Data.Save(conf.Path)
88 | }
89 |
90 | var (
91 | appConf = config.App{
92 | CheckUpdate: true,
93 | Defaults: config.DefaultValue{
94 | LogLevel: consts.LogLevelInfo,
95 | LogMaxDays: consts.DefaultLogMaxDays,
96 | TCPMux: true,
97 | TLSEnable: true,
98 | },
99 | }
100 | confDB *walk.DataBinder
101 | )
102 |
103 | func loadAllConfs() ([]*Conf, error) {
104 | // Load and migrate application configuration.
105 | if lang, _ := config.UnmarshalAppConf(config.DefaultAppFile, &appConf); lang != nil {
106 | if _, ok := i18n.IDToName[*lang]; ok {
107 | appConf.Lang = *lang
108 | if saveAppConfig() == nil {
109 | os.Remove(config.LangFile)
110 | }
111 | } else {
112 | os.Remove(config.LangFile)
113 | }
114 | }
115 | // Find all config files in `profiles` directory.
116 | files, err := filepath.Glob(PathOfConf("*.conf"))
117 | if err != nil {
118 | return nil, err
119 | }
120 | cfgList := make([]*Conf, 0)
121 | for _, f := range files {
122 | if conf, err := config.UnmarshalClientConf(f); err == nil {
123 | c := NewConf(f, conf)
124 | if c.Name() == "" {
125 | conf.ClientCommon.Name = util.FileNameWithoutExt(f)
126 | }
127 | cfgList = append(cfgList, c)
128 | }
129 | }
130 | slices.SortStableFunc(cfgList, func(a, b *Conf) int {
131 | i := slices.Index(appConf.Sort, util.FileNameWithoutExt(a.Path))
132 | j := slices.Index(appConf.Sort, util.FileNameWithoutExt(b.Path))
133 | if i < 0 && j >= 0 {
134 | return 1
135 | } else if j < 0 && i >= 0 {
136 | return -1
137 | }
138 | return i - j
139 | })
140 | return cfgList, nil
141 | }
142 |
143 | // ConfBinder is the view model of configs
144 | type ConfBinder struct {
145 | // Current selected config
146 | Current *Conf
147 | // List of configs
148 | List func() []*Conf
149 | // Set Config state
150 | SetState func(conf *Conf, state consts.ConfigState) bool
151 | // Commit will save the given config and try to reload service
152 | Commit func(conf *Conf, flag runFlag)
153 | }
154 |
155 | // getCurrentConf returns the current selected config
156 | func getCurrentConf() *Conf {
157 | if confDB != nil {
158 | if ds, ok := confDB.DataSource().(*ConfBinder); ok {
159 | return ds.Current
160 | }
161 | }
162 | return nil
163 | }
164 |
165 | // setCurrentConf set the current selected config, the views will get notified
166 | func setCurrentConf(conf *Conf) {
167 | if confDB != nil {
168 | if ds, ok := confDB.DataSource().(*ConfBinder); ok {
169 | ds.Current = conf
170 | confDB.Reset()
171 | }
172 | }
173 | }
174 |
175 | // commitConf will save the given config and try to reload service
176 | func commitConf(conf *Conf, flag runFlag) {
177 | if confDB != nil {
178 | if ds, ok := confDB.DataSource().(*ConfBinder); ok {
179 | ds.Commit(conf, flag)
180 | }
181 | }
182 | }
183 |
184 | // getConfList returns a list of all configs.
185 | func getConfList() []*Conf {
186 | if confDB != nil {
187 | if ds, ok := confDB.DataSource().(*ConfBinder); ok {
188 | return ds.List()
189 | }
190 | }
191 | return nil
192 | }
193 |
194 | func setConfState(conf *Conf, state consts.ConfigState) bool {
195 | if confDB != nil {
196 | if ds, ok := confDB.DataSource().(*ConfBinder); ok {
197 | return ds.SetState(conf, state)
198 | }
199 | }
200 | return false
201 | }
202 |
203 | func newDefaultClientConfig() *config.ClientConfig {
204 | return &config.ClientConfig{
205 | ClientCommon: appConf.Defaults.AsClientConfig(),
206 | }
207 | }
208 |
209 | func saveAppConfig() error {
210 | return appConf.Save(config.DefaultAppFile)
211 | }
212 |
213 | func setConfOrder(cfgList []*Conf) {
214 | appConf.Sort = lo.Map(cfgList, func(item *Conf, index int) string {
215 | return util.FileNameWithoutExt(item.Path)
216 | })
217 | saveAppConfig()
218 | }
219 |
--------------------------------------------------------------------------------
/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 |
9 | #define TITLE_EN_US "FRP Manager Setup"
10 | #define TITLE_ZH_CN "FRP 管理器安装程序"
11 | #define TITLE_ZH_TW "FRP 管理器安裝程式"
12 | #define TITLE_JA_JP "FRP マネージャーインストーラー"
13 | #define TITLE_KO_KR "FRP 관리자 설치 프로그램"
14 | #define TITLE_ES_ES "Instalación de Administrador de FRP"
15 |
16 | LANGUAGE LANG_NEUTRAL, SUBLANG_NEUTRAL
17 | CREATEPROCESS_MANIFEST_RESOURCE_ID RT_MANIFEST manifest.xml
18 | IDI_ICON ICON "../icon/app.ico"
19 | IDR_MSI RCDATA EXPAND(MSI_FILE)
20 |
21 | #define VERSIONINFO_TEMPLATE(block_id, lang_id, charset_id, file_desc, product_name) \
22 | VS_VERSION_INFO VERSIONINFO \
23 | FILEVERSION VERSION_ARRAY \
24 | PRODUCTVERSION VERSION_ARRAY \
25 | FILEFLAGSMASK VS_FFI_FILEFLAGSMASK \
26 | FILEFLAGS 0x0 \
27 | FILEOS VOS__WINDOWS32 \
28 | FILETYPE VFT_APP \
29 | FILESUBTYPE VFT2_UNKNOWN \
30 | BEGIN \
31 | BLOCK "StringFileInfo" \
32 | BEGIN \
33 | BLOCK block_id \
34 | BEGIN \
35 | VALUE "CompanyName", "FRP Manager Project" \
36 | VALUE "FileDescription", file_desc \
37 | VALUE "FileVersion", EXPAND(VERSION_STR) \
38 | VALUE "InternalName", "frpmgr-setup" \
39 | VALUE "LegalCopyright", "Copyright © FRP Manager Project" \
40 | VALUE "OriginalFilename", EXPAND(FILENAME) \
41 | VALUE "ProductName", product_name \
42 | VALUE "ProductVersion", EXPAND(VERSION_STR) \
43 | VALUE "Comments", "https://github.com/koho/frpmgr" \
44 | END \
45 | END \
46 | BLOCK "VarFileInfo" \
47 | BEGIN \
48 | VALUE "Translation", lang_id, charset_id \
49 | END \
50 | END
51 |
52 | LANGUAGE LANG_ENGLISH, SUBLANG_DEFAULT
53 | VERSIONINFO_TEMPLATE(
54 | "040904B0", 0x0409, 1200,
55 | TITLE_EN_US,
56 | "FRP Manager"
57 | )
58 |
59 | LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_SIMPLIFIED
60 | VERSIONINFO_TEMPLATE(
61 | "080404B0", 0x0804, 1200,
62 | TITLE_ZH_CN,
63 | "FRP 管理器"
64 | )
65 |
66 | LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_TRADITIONAL
67 | VERSIONINFO_TEMPLATE(
68 | "040404B0", 0x0404, 1200,
69 | TITLE_ZH_TW,
70 | "FRP 管理器"
71 | )
72 |
73 | LANGUAGE LANG_JAPANESE, SUBLANG_DEFAULT
74 | VERSIONINFO_TEMPLATE(
75 | "041104B0", 0x0411, 1200,
76 | TITLE_JA_JP,
77 | "FRP マネージャ"
78 | )
79 |
80 | LANGUAGE LANG_KOREAN, SUBLANG_DEFAULT
81 | VERSIONINFO_TEMPLATE(
82 | "041204B0", 0x0412, 1200,
83 | TITLE_KO_KR,
84 | "FRP 관리자"
85 | )
86 |
87 | LANGUAGE LANG_SPANISH, SUBLANG_SPANISH_MODERN
88 | VERSIONINFO_TEMPLATE(
89 | "0C0A04B0", 0x0C0A, 1200,
90 | TITLE_ES_ES,
91 | "Administrador de FRP"
92 | )
93 |
94 | #define LANG_DIALOG_TEMPLATE(title, description, ok, cancel) \
95 | IDD_LANG_DIALOG DIALOGEX 0, 0, 252, 69 \
96 | STYLE DS_SETFONT | DS_MODALFRAME | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU \
97 | CAPTION title \
98 | FONT 9, "Segoe UI" \
99 | BEGIN \
100 | COMBOBOX IDC_LANG_COMBO, 34, 30, 211, 374, CBS_DROPDOWNLIST | CBS_AUTOHSCROLL | WS_CHILD | WS_VISIBLE | WS_VSCROLL | WS_TABSTOP \
101 | DEFPUSHBUTTON ok, IDOK, 141, 48, 50, 14, BS_DEFPUSHBUTTON | WS_CHILD | WS_VISIBLE | WS_TABSTOP \
102 | PUSHBUTTON cancel, IDCANCEL, 195, 48, 50, 14, BS_PUSHBUTTON | WS_CHILD | WS_VISIBLE | WS_TABSTOP \
103 | LTEXT description, IDC_STATIC, 34, 8, 211, 20, SS_LEFT | SS_NOPREFIX | WS_CHILD | WS_VISIBLE | WS_GROUP \
104 | ICON IDI_ICON, IDC_STATIC, 7, 7, 21, 20, SS_ICON | WS_CHILD | WS_VISIBLE \
105 | END
106 |
107 | LANGUAGE LANG_ENGLISH, SUBLANG_DEFAULT
108 | LANG_DIALOG_TEMPLATE(
109 | TITLE_EN_US, "Select the language for the installation from the choices below.", "OK", "Cancel"
110 | )
111 |
112 | LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_SIMPLIFIED
113 | LANG_DIALOG_TEMPLATE(
114 | TITLE_ZH_CN, "从下列选项中选择安装语言。", "确定", "取消"
115 | )
116 |
117 | LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_TRADITIONAL
118 | LANG_DIALOG_TEMPLATE(
119 | TITLE_ZH_TW, "從下列選項中選擇安裝語言。", "確定", "取消"
120 | )
121 |
122 | LANGUAGE LANG_JAPANESE, SUBLANG_DEFAULT
123 | LANG_DIALOG_TEMPLATE(
124 | TITLE_JA_JP, "以下のオプションからインストール言語を選択してください。", "OK", "キャンセル"
125 | )
126 |
127 | LANGUAGE LANG_KOREAN, SUBLANG_DEFAULT
128 | LANG_DIALOG_TEMPLATE(
129 | TITLE_KO_KR, "아래 옵션에서 설치 언어를 선택하세요.", "확인", "취소"
130 | )
131 |
132 | LANGUAGE LANG_SPANISH, SUBLANG_SPANISH_MODERN
133 | LANG_DIALOG_TEMPLATE(
134 | TITLE_ES_ES, "Seleccione el idioma para la instalación entre las opciones siguientes.", "Aceptar", "Cancelar"
135 | )
136 |
137 | LANGUAGE LANG_ENGLISH, SUBLANG_DEFAULT
138 | STRINGTABLE
139 | BEGIN
140 | IDS_TITLE TITLE_EN_US
141 | IDS_MANAGEMENT "Manage Current Product"
142 | IDS_OPERATION "Select the action you want to perform."
143 | IDS_REINSTALL "Reinstall with ""%1""%rThis operation requires suspending the running services.\0 "
144 | IDS_UNINSTALL "Uninstall"
145 | END
146 |
147 | LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_SIMPLIFIED
148 | STRINGTABLE
149 | BEGIN
150 | IDS_TITLE TITLE_ZH_CN
151 | IDS_MANAGEMENT "管理当前产品"
152 | IDS_OPERATION "选择希望执行的操作。"
153 | IDS_REINSTALL "以 “%1” 重新安装%r此操作需要暂停正在运行的服务。\0 "
154 | IDS_UNINSTALL "卸载"
155 | END
156 |
157 | LANGUAGE LANG_CHINESE, SUBLANG_CHINESE_TRADITIONAL
158 | STRINGTABLE
159 | BEGIN
160 | IDS_TITLE TITLE_ZH_TW
161 | IDS_MANAGEMENT "管理現行產品"
162 | IDS_OPERATION "選取您要執行的作業。"
163 | IDS_REINSTALL "以「%1」重新安裝%r此操作需要暫停正在運作的服務。\0 "
164 | IDS_UNINSTALL "移除"
165 | END
166 |
167 | LANGUAGE LANG_JAPANESE, SUBLANG_DEFAULT
168 | STRINGTABLE
169 | BEGIN
170 | IDS_TITLE TITLE_JA_JP
171 | IDS_MANAGEMENT "現在のインストールの管理"
172 | IDS_OPERATION "実行するアクションを選択します。"
173 | IDS_REINSTALL "「%1」で再インストールします%rこの操作では実行中のサービスを一時停止する必要があります。\0 "
174 | IDS_UNINSTALL "アンインストール"
175 | END
176 |
177 | LANGUAGE LANG_KOREAN, SUBLANG_DEFAULT
178 | STRINGTABLE
179 | BEGIN
180 | IDS_TITLE TITLE_KO_KR
181 | IDS_MANAGEMENT "현재 제품 관리"
182 | IDS_OPERATION "수행하고자 하는 작업을 선택하세요."
183 | IDS_REINSTALL """%1""로 다시 설치%r이 작업을 수행하려면 실행 중인 서비스를 중단해야 합니다.\0 "
184 | IDS_UNINSTALL "제거하다"
185 | END
186 |
187 | LANGUAGE LANG_SPANISH, SUBLANG_SPANISH_MODERN
188 | STRINGTABLE
189 | BEGIN
190 | IDS_TITLE TITLE_ES_ES
191 | IDS_MANAGEMENT "Administrar el producto actual"
192 | IDS_OPERATION "Seleccione la acción que desea realizar."
193 | IDS_REINSTALL "Reinstalar con ""%1""%rEsta operación requiere suspender los servicios en ejecución.\0 "
194 | IDS_UNINSTALL "Desinstalar"
195 | END
196 |
--------------------------------------------------------------------------------
/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 | welcomeView *walk.Composite
22 |
23 | svcCleanup func() error
24 | }
25 |
26 | func NewConfPage(cfgList []*Conf) *ConfPage {
27 | v := new(ConfPage)
28 | v.confView = NewConfView(cfgList)
29 | v.detailView = NewDetailView()
30 | return v
31 | }
32 |
33 | func (cp *ConfPage) Page() TabPage {
34 | return TabPage{
35 | AssignTo: &cp.TabPage,
36 | Title: i18n.Sprintf("Configuration"),
37 | Layout: HBox{},
38 | DataBinder: DataBinder{
39 | AssignTo: &confDB,
40 | DataSource: &ConfBinder{
41 | List: cp.confView.model.List,
42 | SetState: cp.confView.model.SetStateByConf,
43 | Commit: func(conf *Conf, flag runFlag) {
44 | if conf != nil {
45 | if err := conf.Save(); err != nil {
46 | showError(err, cp.Form())
47 | return
48 | }
49 | if flag == runFlagForceStart {
50 | // The service of config is stopped by other code, but it should be restarted
51 | } else if conf.State == consts.ConfigStateStarted {
52 | // Hot-Reloading frp configuration
53 | if flag == runFlagReload {
54 | if err := services.ReloadService(conf.Path); err != nil {
55 | showError(err, cp.Form())
56 | }
57 | return
58 | }
59 | // The service is running, we should stop it and restart it later
60 | if err := cp.detailView.panelView.StopService(conf); err != nil {
61 | showError(err, cp.Form())
62 | return
63 | }
64 | } else {
65 | // The service is stopped all the time, there's nothing to do about it
66 | return
67 | }
68 | if err := cp.detailView.panelView.StartService(conf); err != nil {
69 | showError(err, cp.Form())
70 | return
71 | }
72 | }
73 | },
74 | },
75 | Name: "conf",
76 | },
77 | Children: []Widget{
78 | cp.confView.View(),
79 | cp.detailView.View(),
80 | cp.createWelcomeView(),
81 | cp.createMultiSelectionView(),
82 | },
83 | }
84 | }
85 |
86 | func (cp *ConfPage) createWelcomeView() Composite {
87 | return Composite{
88 | AssignTo: &cp.welcomeView,
89 | Visible: Bind("confView.SelectedCount == 0"),
90 | Layout: HBox{},
91 | Children: []Widget{
92 | HSpacer{},
93 | Composite{
94 | Layout: VBox{Spacing: 20},
95 | Children: []Widget{
96 | VSpacer{},
97 | PushButton{
98 | Text: i18n.Sprintf("New Configuration"),
99 | MinSize: Size{Width: 200},
100 | OnClicked: cp.confView.editNew,
101 | },
102 | PushButton{
103 | Text: i18n.Sprintf("Import from File"),
104 | MinSize: Size{Width: 200},
105 | OnClicked: cp.confView.onFileImport,
106 | },
107 | VSpacer{},
108 | },
109 | },
110 | HSpacer{},
111 | },
112 | }
113 | }
114 |
115 | func (cp *ConfPage) createMultiSelectionView() Composite {
116 | count := "{Count}"
117 | text := i18n.Sprintf("Delete %s configs", count)
118 | expr := "confView.SelectedCount"
119 | if i := strings.Index(text, count); i >= 0 {
120 | if left := text[:i]; left != "" {
121 | expr = "'" + left + "' + " + expr
122 | }
123 | if right := text[i+len(count):]; right != "" {
124 | expr += " + '" + right + "'"
125 | }
126 | }
127 | return Composite{
128 | Visible: Bind("confView.SelectedCount > 1"),
129 | Layout: HBox{},
130 | Children: []Widget{
131 | HSpacer{},
132 | PushButton{
133 | Text: Bind(expr),
134 | MinSize: Size{Width: 200},
135 | OnClicked: cp.confView.onDelete,
136 | },
137 | HSpacer{},
138 | },
139 | }
140 | }
141 |
142 | func (cp *ConfPage) OnCreate() {
143 | // Create all child views
144 | cp.confView.OnCreate()
145 | cp.detailView.OnCreate()
146 | // Select the first config
147 | if cp.confView.model.RowCount() > 0 {
148 | cp.confView.listView.SetCurrentIndex(0)
149 | }
150 | cp.confView.model.RowEdited().Attach(func(i int) {
151 | cp.detailView.panelView.Invalidate(false)
152 | })
153 | cp.addVisibleChangedListener()
154 | cleanup, err := services.WatchConfigServices(func() []string {
155 | return lo.Map(getConfList(), func(item *Conf, index int) string {
156 | return item.Path
157 | })
158 | }, func(path string, state consts.ConfigState) {
159 | cp.Synchronize(func() {
160 | if cp.confView.model.SetStateByPath(path, state) {
161 | if conf := getCurrentConf(); conf != nil && conf.Path == path {
162 | cp.detailView.panelView.setState(state)
163 | if !cp.Visible() {
164 | return
165 | }
166 | if state == consts.ConfigStateStarted {
167 | cp.detailView.proxyView.startTracker(true)
168 | } else {
169 | if cp.detailView.proxyView.stopTracker() {
170 | cp.detailView.proxyView.resetProxyState(-1)
171 | }
172 | }
173 | }
174 | }
175 | })
176 | })
177 | if err != nil {
178 | showError(err, cp.Form())
179 | return
180 | }
181 | cp.svcCleanup = cleanup
182 | }
183 |
184 | func (cp *ConfPage) addVisibleChangedListener() {
185 | var oldState consts.ConfigState
186 | cp.VisibleChanged().Attach(func() {
187 | if cp.Visible() {
188 | defer func() {
189 | oldState = consts.ConfigStateUnknown
190 | }()
191 | if conf := getCurrentConf(); conf != nil {
192 | if conf.State == consts.ConfigStateStarted {
193 | cp.detailView.proxyView.startTracker(true)
194 | } else if oldState == consts.ConfigStateStarted {
195 | cp.detailView.proxyView.resetProxyState(-1)
196 | }
197 | }
198 | } else {
199 | cp.detailView.proxyView.stopTracker()
200 | if conf := getCurrentConf(); conf != nil {
201 | oldState = conf.State
202 | }
203 | }
204 | })
205 | }
206 |
207 | func (cp *ConfPage) Close() error {
208 | if cp.svcCleanup != nil {
209 | return cp.svcCleanup()
210 | }
211 | cp.detailView.proxyView.stopTracker()
212 | return nil
213 | }
214 |
215 | func warnConfigRemoved(owner walk.Form, name string) {
216 | showWarningMessage(owner, i18n.Sprintf("Config already removed"), i18n.Sprintf("The config \"%s\" already removed.", name))
217 | }
218 |
--------------------------------------------------------------------------------
/ui/ui.go:
--------------------------------------------------------------------------------
1 | package ui
2 |
3 | import (
4 | "os"
5 | "path/filepath"
6 | "strings"
7 | "unsafe"
8 |
9 | "github.com/lxn/walk"
10 | . "github.com/lxn/walk/declarative"
11 | "github.com/lxn/win"
12 | "github.com/samber/lo"
13 | "golang.org/x/sys/windows"
14 |
15 | "github.com/koho/frpmgr/i18n"
16 | "github.com/koho/frpmgr/pkg/res"
17 | "github.com/koho/frpmgr/pkg/util"
18 | )
19 |
20 | const AppName = "FRP Manager"
21 |
22 | var AppLocalName = i18n.Sprintf(AppName)
23 |
24 | func init() {
25 | walk.SetTranslationFunc(func(source string, context ...string) string {
26 | translation := i18n.Sprintf(source)
27 | s1 := strings.ReplaceAll(translation, "%!f(MISSING)", "%.f")
28 | return strings.ReplaceAll(s1, "%!f(BADINDEX)", "%.f")
29 | })
30 | }
31 |
32 | type FRPManager struct {
33 | *walk.MainWindow
34 |
35 | tabs *walk.TabWidget
36 | confPage *ConfPage
37 | logPage *LogPage
38 | prefPage *PrefPage
39 | aboutPage *AboutPage
40 | }
41 |
42 | func (fm *FRPManager) idealSize(empty bool) walk.Size {
43 | if empty {
44 | fm.confPage.welcomeView.SetVisible(false)
45 | defer fm.confPage.welcomeView.SetVisible(true)
46 | fm.confPage.detailView.SetVisible(true)
47 | defer fm.confPage.detailView.SetVisible(false)
48 | }
49 | // Minimum window height.
50 | confPageHeight := fm.confPage.SizeHint().Height
51 | maxPageHeight := max(
52 | confPageHeight,
53 | fm.logPage.SizeHint().Height,
54 | fm.prefPage.SizeHint().Height,
55 | fm.aboutPage.SizeHint().Height,
56 | )
57 | margins := fm.Layout().Margins()
58 | size := fm.tabs.SizeHint()
59 | bias := fm.confPage.detailView.sizeBias()
60 | size.Width += bias.Width + walk.IntFrom96DPI(margins.HNear+margins.HFar, fm.DPI())
61 | size.Height += bias.Height + walk.IntFrom96DPI(margins.VNear+margins.VFar, fm.DPI()) - maxPageHeight + confPageHeight
62 | return size
63 | }
64 |
65 | func RunUI() error {
66 | var err error
67 | // Make sure the config directory exists.
68 | if err = os.MkdirAll(PathOfConf(""), os.ModePerm); err != nil {
69 | return err
70 | }
71 | cfgList, err := loadAllConfs()
72 | if err != nil {
73 | return err
74 | }
75 | if appConf.Password != "" {
76 | if r, err := NewValidateDialog().Run(); err != nil || r != win.IDOK {
77 | return err
78 | }
79 | }
80 | fm := new(FRPManager)
81 | fm.confPage = NewConfPage(cfgList)
82 | fm.logPage, err = NewLogPage()
83 | if err != nil {
84 | return err
85 | }
86 | fm.prefPage = NewPrefPage()
87 | fm.aboutPage = NewAboutPage()
88 | mw := MainWindow{
89 | Icon: loadLogoIcon(32),
90 | AssignTo: &fm.MainWindow,
91 | Title: AppLocalName,
92 | Persistent: true,
93 | Visible: false,
94 | Layout: VBox{Margins: Margins{Left: 5, Top: 5, Right: 5, Bottom: 5}},
95 | Font: res.TextRegular,
96 | Children: []Widget{
97 | TabWidget{
98 | AssignTo: &fm.tabs,
99 | Pages: []TabPage{
100 | fm.confPage.Page(),
101 | fm.logPage.Page(),
102 | fm.prefPage.Page(),
103 | fm.aboutPage.Page(),
104 | },
105 | },
106 | },
107 | OnDropFiles: fm.confPage.confView.ImportFiles,
108 | }
109 | if err = mw.Create(); err != nil {
110 | return err
111 | }
112 | // Initialize child pages
113 | fm.confPage.OnCreate()
114 | fm.logPage.OnCreate()
115 | fm.prefPage.OnCreate()
116 | fm.aboutPage.OnCreate()
117 | // Resize window
118 | fm.SetClientSizePixels(fm.idealSize(len(cfgList) == 0))
119 | fm.Closing().Attach(func(canceled *bool, reason walk.CloseReason) {
120 | // Save window state.
121 | var wp win.WINDOWPLACEMENT
122 | wp.Length = uint32(unsafe.Sizeof(wp))
123 | if win.GetWindowPlacement(fm.Handle(), &wp) {
124 | appConf.Position = []int32{wp.RcNormalPosition.Left, wp.RcNormalPosition.Top}
125 | saveAppConfig()
126 | }
127 | })
128 | // Restore window state.
129 | if len(appConf.Position) > 1 {
130 | bounds := fm.BoundsPixels()
131 | wp := win.WINDOWPLACEMENT{
132 | Flags: 0,
133 | ShowCmd: win.SW_SHOWNORMAL,
134 | PtMinPosition: win.POINT{X: -1, Y: -1},
135 | PtMaxPosition: win.POINT{X: -1, Y: -1},
136 | RcNormalPosition: win.RECT{
137 | Left: appConf.Position[0],
138 | Top: appConf.Position[1],
139 | Right: appConf.Position[0] + int32(bounds.Width),
140 | Bottom: appConf.Position[1] + int32(bounds.Height),
141 | },
142 | }
143 | wp.Length = uint32(unsafe.Sizeof(wp))
144 | win.SetWindowPlacement(fm.Handle(), &wp)
145 | }
146 | fm.Show()
147 | fm.Run()
148 | fm.confPage.Close()
149 | fm.logPage.Close()
150 | return nil
151 | }
152 |
153 | func showError(err error, owner walk.Form) bool {
154 | if err == nil {
155 | return false
156 | }
157 | showErrorMessage(owner, "", err.Error())
158 | return true
159 | }
160 |
161 | func showErrorMessage(owner walk.Form, title, message string) {
162 | if title == "" {
163 | title = AppLocalName
164 | }
165 | walk.MsgBox(owner, title, message, walk.MsgBoxIconError)
166 | }
167 |
168 | func showWarningMessage(owner walk.Form, title, message string) {
169 | walk.MsgBox(owner, title, message, walk.MsgBoxIconWarning)
170 | }
171 |
172 | func showInfoMessage(owner walk.Form, title, message string) {
173 | if title == "" {
174 | title = AppLocalName
175 | }
176 | walk.MsgBox(owner, title, message, walk.MsgBoxIconInformation)
177 | }
178 |
179 | // openPath opens a file or url with default application
180 | func openPath(path string) {
181 | if path == "" {
182 | return
183 | }
184 | win.ShellExecute(0, nil, windows.StringToUTF16Ptr(path), nil, nil, win.SW_SHOWNORMAL)
185 | }
186 |
187 | // openFolder opens the explorer and select the given file
188 | func openFolder(path string) {
189 | if path == "" {
190 | return
191 | }
192 | if absPath, err := filepath.Abs(path); err == nil {
193 | win.ShellExecute(0, nil, windows.StringToUTF16Ptr(`explorer`),
194 | windows.StringToUTF16Ptr(`/select,`+absPath), nil, win.SW_SHOWNORMAL)
195 | }
196 | }
197 |
198 | // openFileDialog shows a file dialog to choose file or directory and sends the selected path to the LineEdit view
199 | func openFileDialog(receiver *walk.LineEdit, title string, filter string, file bool) error {
200 | dlg := walk.FileDialog{
201 | Filter: filter + res.FilterAllFiles,
202 | Title: title,
203 | }
204 | var ok bool
205 | var err error
206 | if file {
207 | ok, err = dlg.ShowOpen(receiver.Form())
208 | } else {
209 | ok, err = dlg.ShowBrowseFolder(receiver.Form())
210 | }
211 | if err != nil {
212 | return err
213 | }
214 | if !ok {
215 | return nil
216 | }
217 | return receiver.SetText(filepath.ToSlash(dlg.FilePath))
218 | }
219 |
220 | // calculateHeadColumnTextWidth returns the estimated display width of the first column
221 | func calculateHeadColumnTextWidth(widgets []Widget, columns int) int {
222 | maxLen := 0
223 | for i := range widgets {
224 | if label, ok := widgets[i].(Label); ok && i%columns == 0 {
225 | if textLen := calculateStringWidth(label.Text.(string)); textLen > maxLen {
226 | maxLen = textLen
227 | }
228 | }
229 | }
230 | return maxLen + 5
231 | }
232 |
233 | // calculateStringWidth returns the estimated display width of the given string
234 | func calculateStringWidth(str string) int {
235 | return lo.Sum(lo.Map(util.RuneSizeInString(str), func(s int, i int) int {
236 | // For better estimation, reduce size for non-ascii character
237 | if s > 1 {
238 | return s - 1
239 | }
240 | return s
241 | })) * 6
242 | }
243 |
--------------------------------------------------------------------------------
/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 |
22 |
23 |
24 | = 603)]]>
25 |
26 |
27 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
44 |
45 | PREVINSTALLFOLDER
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | NOT WIX_UPGRADE_DETECTED
61 | NOT WIX_UPGRADE_DETECTED
62 |
63 |
64 | WIX_UPGRADE_DETECTED
65 | WIX_UPGRADE_DETECTED
66 |
67 |
68 | NOT Installed
69 |
70 |
71 |
72 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
112 |
113 |
114 |
115 |
118 |
119 |
120 | (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")
121 |
122 |
123 |
126 |
127 |
128 | NOT ((UPGRADINGPRODUCTCODE OR SAVESTATE) AND (REMOVE="ALL"))
129 |
130 |
131 |
134 |
135 |
136 |
137 | REMOVE="ALL"
138 |
139 |
140 |
143 |
144 |
145 |
146 | (NOT UPGRADINGPRODUCTCODE) AND (NOT SAVESTATE) AND (REMOVE="ALL")
147 |
148 |
149 |
152 |
153 |
154 |
155 | NOT (REMOVE="ALL")
156 |
157 |
158 |
161 |
162 |
163 |
164 | NOT (REMOVE="ALL")
165 |
166 |
167 |
168 |
--------------------------------------------------------------------------------
/.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 | strategy:
12 | matrix:
13 | architecture: [x64, x86, arm64]
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.24'
22 |
23 | - name: Setup VS environment
24 | shell: powershell
25 | run: |
26 | echo "$(vswhere.exe -latest -property installationPath)\VC\Auxiliary\Build" >> $env:GITHUB_PATH
27 | @"
28 | @echo off
29 | setlocal enabledelayedexpansion
30 | set SRC_DIR=%~1
31 | :safe_copy
32 | shift
33 | if "%~1"=="" exit /b 0
34 | if "%~2"=="" exit /b 1
35 | set SRC_FILE=%SRC_DIR%\%~1
36 | set DST_FILE=%~2\%~1
37 | if not "%~x1" == ".msi" (
38 | set SRC_FILE_UNSIGNED=!SRC_FILE!.unsigned
39 | copy /Y "!SRC_FILE!" "!SRC_FILE_UNSIGNED!"
40 | "%SIGNTOOL%" remove /s "!SRC_FILE_UNSIGNED!" || exit /b 1
41 | call :pe_compare "!SRC_FILE_UNSIGNED!" "!DST_FILE!" || (
42 | echo File checksum mismatch: !SRC_FILE!
43 | exit /b 1))
44 | copy /Y "!SRC_FILE!" "!DST_FILE!"
45 | shift
46 | goto :safe_copy
47 | :pe_compare
48 | python -c "import os;import sys;from ctypes import *;header_sum,checksum1,checksum2=c_ulong(0),c_ulong(0),c_ulong(0);src_size,dst_size=os.path.getsize(sys.argv[1]),os.path.getsize(sys.argv[2]);f=open(sys.argv[1],'r+');f.seek(dst_size,os.SEEK_SET);f.truncate();f.close();assert windll.imagehlp.MapFileAndCheckSumW(sys.argv[1],byref(header_sum),byref(checksum1))==0;assert windll.imagehlp.MapFileAndCheckSumW(sys.argv[2],byref(header_sum),byref(checksum2))==0;assert checksum1.value==checksum2.value" %1 %2
49 | goto :eof
50 | "@ | Out-File -Encoding ascii -FilePath safe_copy.bat
51 |
52 | - name: Setup toolchain
53 | shell: cmd
54 | run: |
55 | curl "https://github.com/mstorsjo/llvm-mingw/releases/download/20250709/llvm-mingw-20250709-msvcrt-x86_64.zip" -o llvm-mingw-20250709-msvcrt-x86_64.zip -L
56 | tar -xf llvm-mingw-20250709-msvcrt-x86_64.zip
57 | echo %CD%\llvm-mingw-20250709-msvcrt-x86_64\bin>>%GITHUB_PATH%
58 | vcvarsall x64 && set WindowsSdkVerBinPath >> %GITHUB_ENV%
59 |
60 | - name: Build main application
61 | shell: cmd
62 | run: build.bat -p ${{ matrix.architecture }}
63 |
64 | - name: Get version info
65 | shell: powershell
66 | run: |
67 | $version = $((Get-Item .\bin\${{ matrix.architecture }}\frpmgr.exe).VersionInfo.ProductVersion)
68 | echo "VERSION=$version" >> $env:GITHUB_ENV
69 | $signtool = $(cmd /C "vcvarsall x64 && where signtool" | Select-Object -Last 1)
70 | echo "SIGNTOOL=$signtool" >> $env:GITHUB_ENV
71 |
72 | - name: Build custom actions
73 | shell: cmd
74 | run: installer\build.bat %VERSION% ${{ matrix.architecture }} actions
75 |
76 | - name: Prepare to upload files
77 | shell: cmd
78 | run: copy /Y installer\build\${{ matrix.architecture }}\actions.dll bin\${{ matrix.architecture }}
79 |
80 | - name: Upload unsigned application
81 | id: upload-unsigned-application
82 | uses: actions/upload-artifact@v4
83 | with:
84 | name: frpmgr-${{ env.VERSION }}-main-${{ matrix.architecture }}-unsigned
85 | path: |
86 | bin/${{ matrix.architecture }}/frpmgr.exe
87 | bin/${{ matrix.architecture }}/actions.dll
88 |
89 | - name: Sign
90 | uses: signpath/github-action-submit-signing-request@v1.2
91 | with:
92 | api-token: '${{ secrets.SIGNPATH_API_TOKEN }}'
93 | organization-id: '${{ secrets.SIGNPATH_ORGANIZATION_ID }}'
94 | project-slug: 'frpmgr'
95 | signing-policy-slug: 'release-signing'
96 | github-artifact-id: '${{ steps.upload-unsigned-application.outputs.artifact-id }}'
97 | wait-for-completion: true
98 | output-artifact-directory: 'dist'
99 |
100 | - name: Verify and copy signed files
101 | shell: cmd
102 | run: safe_copy dist frpmgr.exe bin\${{ matrix.architecture }} actions.dll installer\build\${{ matrix.architecture }}
103 |
104 | - name: Build MSI installer
105 | shell: cmd
106 | run: installer\build.bat %VERSION% ${{ matrix.architecture }} msi
107 |
108 | - name: Upload unsigned installer
109 | id: upload-unsigned-installer
110 | uses: actions/upload-artifact@v4
111 | with:
112 | name: frpmgr-${{ env.VERSION }}-installer-${{ matrix.architecture }}-unsigned
113 | path: installer/build/${{ matrix.architecture }}/frpmgr.msi
114 |
115 | - name: Sign
116 | uses: signpath/github-action-submit-signing-request@v1.2
117 | with:
118 | api-token: '${{ secrets.SIGNPATH_API_TOKEN }}'
119 | organization-id: '${{ secrets.SIGNPATH_ORGANIZATION_ID }}'
120 | project-slug: 'frpmgr'
121 | signing-policy-slug: 'release-signing'
122 | github-artifact-id: '${{ steps.upload-unsigned-installer.outputs.artifact-id }}'
123 | wait-for-completion: true
124 | output-artifact-directory: 'dist'
125 |
126 | - name: Verify and copy signed files
127 | shell: cmd
128 | run: safe_copy dist frpmgr.msi installer\build\${{ matrix.architecture }}
129 |
130 | - name: Build EXE bootstrapper
131 | shell: cmd
132 | run: installer\build.bat %VERSION% ${{ matrix.architecture }} setup
133 |
134 | - name: Upload unsigned bootstrapper
135 | id: upload-unsigned-bootstrapper
136 | uses: actions/upload-artifact@v4
137 | with:
138 | name: frpmgr-${{ env.VERSION }}-setup-${{ matrix.architecture }}-unsigned
139 | path: installer/build/${{ matrix.architecture }}/setup.exe
140 |
141 | - name: Sign
142 | uses: signpath/github-action-submit-signing-request@v1.2
143 | with:
144 | api-token: '${{ secrets.SIGNPATH_API_TOKEN }}'
145 | organization-id: '${{ secrets.SIGNPATH_ORGANIZATION_ID }}'
146 | project-slug: 'frpmgr'
147 | signing-policy-slug: 'release-signing'
148 | github-artifact-id: '${{ steps.upload-unsigned-bootstrapper.outputs.artifact-id }}'
149 | wait-for-completion: true
150 | output-artifact-directory: 'dist'
151 |
152 | - name: Verify and copy signed files
153 | shell: cmd
154 | run: safe_copy dist setup.exe installer\build\${{ matrix.architecture }}
155 |
156 | - name: Create release files
157 | shell: cmd
158 | run: installer\build.bat %VERSION% ${{ matrix.architecture }} dist
159 |
160 | - name: Upload release files
161 | uses: actions/upload-artifact@v4
162 | with:
163 | name: frpmgr-${{ env.VERSION }}-dist-${{ matrix.architecture }}
164 | path: |
165 | bin/*.exe
166 | bin/*.zip
167 |
168 | release:
169 | name: Release
170 | needs: build
171 | runs-on: ubuntu-latest
172 | steps:
173 | - name: Get version info
174 | run: |
175 | tag_name="${{ github.event.release.tag_name }}"
176 | echo "VERSION=${tag_name#v}" >> $GITHUB_ENV
177 |
178 | - name: Collect files
179 | uses: actions/download-artifact@v4
180 | with:
181 | pattern: frpmgr-${{ env.VERSION }}-dist-*
182 | merge-multiple: true
183 |
184 | - name: Upload release assets
185 | uses: shogo82148/actions-upload-release-asset@v1
186 | env:
187 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
188 | with:
189 | upload_url: ${{ github.event.release.upload_url }}
190 | asset_path: |
191 | ./*.exe
192 | ./*.zip
193 |
--------------------------------------------------------------------------------
/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.LogFile)
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/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/consts"
14 | "github.com/koho/frpmgr/pkg/res"
15 | "github.com/koho/frpmgr/pkg/util"
16 | "github.com/koho/frpmgr/services"
17 | )
18 |
19 | var configStateDescription = map[consts.ConfigState]string{
20 | consts.ConfigStateUnknown: i18n.Sprintf("Unknown"),
21 | consts.ConfigStateStarted: i18n.Sprintf("Running"),
22 | consts.ConfigStateStopped: i18n.Sprintf("Stopped"),
23 | consts.ConfigStateStarting: i18n.Sprintf("Starting"),
24 | consts.ConfigStateStopping: i18n.Sprintf("Stopping"),
25 | }
26 |
27 | type PanelView struct {
28 | *walk.GroupBox
29 |
30 | stateText *walk.Label
31 | stateImage *walk.ImageView
32 | addressText *walk.Label
33 | protoText *walk.Label
34 | protoImage *walk.ImageView
35 | toggleBtn *walk.PushButton
36 | }
37 |
38 | func NewPanelView() *PanelView {
39 | return new(PanelView)
40 | }
41 |
42 | func (pv *PanelView) View() Widget {
43 | var cpIcon *walk.CustomWidget
44 | cpIconColor := res.ColorDarkGray
45 | setCopyIconColor := func(button walk.MouseButton, color walk.Color) {
46 | if button == walk.LeftButton {
47 | cpIconColor = color
48 | cpIcon.Invalidate()
49 | }
50 | }
51 | return GroupBox{
52 | AssignTo: &pv.GroupBox,
53 | Title: "",
54 | Layout: Grid{Margins: Margins{Left: 10, Top: 10, Right: 10, Bottom: 10}, Spacing: 10},
55 | Children: []Widget{
56 | Label{Text: i18n.SprintfColon("Status"), Row: 0, Column: 0, Alignment: AlignHFarVCenter},
57 | Label{Text: i18n.SprintfColon("Server Address"), Row: 1, Column: 0, Alignment: AlignHFarVCenter},
58 | Label{Text: i18n.SprintfColon("Protocol"), Row: 2, Column: 0, Alignment: AlignHFarVCenter},
59 | Composite{
60 | Layout: HBox{SpacingZero: true, MarginsZero: true},
61 | Row: 0, Column: 1,
62 | Alignment: AlignHNearVCenter,
63 | Children: []Widget{
64 | ImageView{AssignTo: &pv.stateImage, Margin: 0},
65 | HSpacer{Size: 4},
66 | Label{AssignTo: &pv.stateText},
67 | },
68 | },
69 | Composite{
70 | Layout: HBox{SpacingZero: true, MarginsZero: true},
71 | Row: 1, Column: 1,
72 | Alignment: AlignHNearVCenter,
73 | Children: []Widget{
74 | Label{AssignTo: &pv.addressText},
75 | HSpacer{Size: 5},
76 | CustomWidget{
77 | AssignTo: &cpIcon,
78 | Background: TransparentBrush{},
79 | ClearsBackground: true,
80 | InvalidatesOnResize: true,
81 | MinSize: Size{Width: 16, Height: 16},
82 | ToolTipText: i18n.Sprintf("Copy"),
83 | PaintPixels: func(canvas *walk.Canvas, updateBounds walk.Rectangle) error {
84 | return drawCopyIcon(canvas, cpIconColor)
85 | },
86 | OnMouseDown: func(x, y int, button walk.MouseButton) {
87 | setCopyIconColor(button, res.ColorLightBlue)
88 | },
89 | OnMouseUp: func(x, y int, button walk.MouseButton) {
90 | setCopyIconColor(button, res.ColorDarkGray)
91 | bounds := cpIcon.ClientBoundsPixels()
92 | if x >= 0 && x <= bounds.Right() && y >= 0 && y <= bounds.Bottom() {
93 | walk.Clipboard().SetText(pv.addressText.Text())
94 | }
95 | },
96 | },
97 | VSpacer{Size: 20},
98 | },
99 | },
100 | Composite{
101 | Layout: HBox{Spacing: 2, MarginsZero: true},
102 | Row: 2, Column: 1,
103 | Alignment: AlignHNearVCenter,
104 | Children: []Widget{
105 | ImageView{
106 | AssignTo: &pv.protoImage,
107 | Image: loadIcon(res.IconFlatLock, 14),
108 | ToolTipText: i18n.Sprintf("Your connection to the server is encrypted"),
109 | },
110 | Label{AssignTo: &pv.protoText},
111 | },
112 | },
113 | Composite{
114 | Layout: HBox{MarginsZero: true},
115 | Row: 3, Column: 1,
116 | Alignment: AlignHNearVCenter,
117 | Children: []Widget{
118 | PushButton{
119 | AssignTo: &pv.toggleBtn,
120 | Text: i18n.Sprintf("Start"),
121 | MaxSize: Size{Width: 80},
122 | Enabled: false,
123 | OnClicked: pv.ToggleService,
124 | },
125 | HSpacer{},
126 | },
127 | },
128 | },
129 | }
130 | }
131 |
132 | func (pv *PanelView) OnCreate() {
133 | pv.setState(consts.ConfigStateUnknown)
134 | }
135 |
136 | func (pv *PanelView) setState(state consts.ConfigState) {
137 | pv.stateImage.SetImage(iconForConfigState(state, 14))
138 | pv.stateText.SetText(configStateDescription[state])
139 | pv.toggleBtn.SetEnabled(state != consts.ConfigStateStarting && state != consts.ConfigStateStopping && state != consts.ConfigStateUnknown)
140 | if state == consts.ConfigStateStarted || state == consts.ConfigStateStopping {
141 | pv.toggleBtn.SetText(i18n.Sprintf("Stop"))
142 | } else {
143 | pv.toggleBtn.SetText(i18n.Sprintf("Start"))
144 | }
145 | }
146 |
147 | func (pv *PanelView) ToggleService() {
148 | conf := getCurrentConf()
149 | if conf == nil {
150 | return
151 | }
152 | var err error
153 | if conf.State == consts.ConfigStateStarted {
154 | if walk.MsgBox(pv.Form(), i18n.Sprintf("Stop config \"%s\"", conf.Name()),
155 | i18n.Sprintf("Are you sure you would like to stop config \"%s\"?", conf.Name()),
156 | walk.MsgBoxYesNo|walk.MsgBoxIconQuestion) == walk.DlgCmdNo {
157 | return
158 | }
159 | err = pv.StopService(conf)
160 | } else {
161 | if !util.FileExists(conf.Path) {
162 | warnConfigRemoved(pv.Form(), conf.Name())
163 | return
164 | }
165 | err = pv.StartService(conf)
166 | }
167 | if err != nil {
168 | showError(err, pv.Form())
169 | }
170 | }
171 |
172 | // StartService creates a daemon service of the given config, then starts it
173 | func (pv *PanelView) StartService(conf *Conf) error {
174 | // Verify the config file
175 | if err := services.VerifyClientConfig(conf.Path); err != nil {
176 | return err
177 | }
178 | // Ensure log directory is valid
179 | if logFile := conf.Data.LogFile; logFile != "" && logFile != "console" {
180 | if err := os.MkdirAll(filepath.Dir(logFile), os.ModePerm); err != nil {
181 | return err
182 | }
183 | }
184 | oldState := conf.State
185 | setConfState(conf, consts.ConfigStateStarting)
186 | pv.setState(consts.ConfigStateStarting)
187 | go func() {
188 | if err := services.InstallService(conf.Name(), conf.Path, !conf.Data.AutoStart()); err != nil {
189 | pv.Synchronize(func() {
190 | showErrorMessage(pv.Form(), i18n.Sprintf("Start config \"%s\"", conf.Name()), err.Error())
191 | if conf.State == consts.ConfigStateStarting {
192 | setConfState(conf, oldState)
193 | if getCurrentConf() == conf {
194 | pv.setState(oldState)
195 | }
196 | }
197 | })
198 | }
199 | }()
200 | return nil
201 | }
202 |
203 | // StopService stops the service of the given config, then removes it
204 | func (pv *PanelView) StopService(conf *Conf) (err error) {
205 | oldState := conf.State
206 | setConfState(conf, consts.ConfigStateStopping)
207 | pv.setState(consts.ConfigStateStopping)
208 | defer func() {
209 | if err != nil {
210 | setConfState(conf, oldState)
211 | pv.setState(oldState)
212 | }
213 | }()
214 | err = services.UninstallService(conf.Path, false)
215 | return
216 | }
217 |
218 | // Invalidate updates views using the current config
219 | func (pv *PanelView) Invalidate(state bool) {
220 | conf := getCurrentConf()
221 | if conf == nil {
222 | pv.SetTitle("")
223 | pv.setState(consts.ConfigStateUnknown)
224 | pv.addressText.SetText("")
225 | pv.protoText.SetText("")
226 | pv.protoImage.SetVisible(false)
227 | return
228 | }
229 | data := conf.Data
230 | if pv.Title() != conf.Name() {
231 | pv.SetTitle(conf.Name())
232 | }
233 | addr := data.ServerAddress
234 | if addr == "" {
235 | addr = "0.0.0.0"
236 | }
237 | if pv.addressText.Text() != addr {
238 | pv.addressText.SetText(addr)
239 | }
240 | pv.protoImage.SetVisible(data.TLSEnable || data.Protocol == consts.ProtoWSS || data.Protocol == consts.ProtoQUIC)
241 | proto := util.GetOrElse(data.Protocol, consts.ProtoTCP)
242 | if proto == consts.ProtoWebsocket {
243 | proto = "ws"
244 | }
245 | proto = strings.ToUpper(proto)
246 | if data.HTTPProxy != "" && data.Protocol != consts.ProtoQUIC {
247 | if u, err := url.Parse(data.HTTPProxy); err == nil {
248 | proto += " + " + strings.ToUpper(u.Scheme)
249 | }
250 | }
251 | if pv.protoText.Text() != proto {
252 | pv.protoText.SetText(proto)
253 | }
254 | if state {
255 | pv.setState(conf.State)
256 | }
257 | }
258 |
--------------------------------------------------------------------------------