Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,32 @@

---

## Web panel

Server mode can expose an optional web panel for editing common settings or the
raw config file. The panel writes changes back to the config file and requires a
process restart before the running proxy uses the new values.

```yaml
webPanel:
enabled: true
listen: 127.0.0.1:8080
path: /secret-panel
password: change-me
cookie:
name: hy_panel
value: optional-static-cookie-secret
secure: false
ipWhitelist:
- 127.0.0.1
- ::1
```

At least one hiding or access-control method is required: `password`,
`cookie.value`, `ipWhitelist`, or a non-default `path`.

---

<div class="feature-grid">
<div>
<h3>🛠️ Jack of all trades</h3>
Expand Down
129 changes: 129 additions & 0 deletions app/cmd/server.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
Expand All @@ -15,6 +16,7 @@ import (
"net/url"
"os"
"os/signal"
"path/filepath"
"slices"
"strconv"
"strings"
Expand All @@ -37,6 +39,7 @@ import (

"github.com/apernet/hysteria/app/v2/internal/firewall"
"github.com/apernet/hysteria/app/v2/internal/utils"
"github.com/apernet/hysteria/app/v2/internal/webpanel"
"github.com/apernet/hysteria/core/v2/server"
"github.com/apernet/hysteria/extras/v2/auth"
"github.com/apernet/hysteria/extras/v2/correctnet"
Expand Down Expand Up @@ -83,6 +86,7 @@ type serverConfig struct {
Outbounds []serverConfigOutboundEntry `mapstructure:"outbounds"`
TrafficStats serverConfigTrafficStats `mapstructure:"trafficStats"`
Masquerade serverConfigMasquerade `mapstructure:"masquerade"`
WebPanel *serverConfigWebPanel `mapstructure:"webPanel"`
}

type serverConfigRealm struct {
Expand Down Expand Up @@ -264,6 +268,21 @@ type serverConfigTrafficStats struct {
Secret string `mapstructure:"secret"`
}

type serverConfigWebPanelCookie struct {
Name string `mapstructure:"name"`
Value string `mapstructure:"value"`
Secure bool `mapstructure:"secure"`
}

type serverConfigWebPanel struct {
Enabled bool `mapstructure:"enabled"`
Listen string `mapstructure:"listen"`
Path string `mapstructure:"path"`
Password string `mapstructure:"password"`
Cookie serverConfigWebPanelCookie `mapstructure:"cookie"`
IPWhitelist []string `mapstructure:"ipWhitelist"`
}

type serverConfigMasqueradeFile struct {
Dir string `mapstructure:"dir"`
}
Expand Down Expand Up @@ -1552,6 +1571,10 @@ func runServer(v *viper.Viper) {
if err != nil {
logger.Fatal("failed to initialize server", zap.Error(err))
}
webPanelServer, err := startWebPanelServer(v.ConfigFileUsed(), config.WebPanel)
if err != nil {
logger.Fatal("failed to initialize web panel", zap.Error(err))
}
if config.Listen != "" {
logger.Info("server up and running", zap.String("listen", config.Listen))
} else {
Expand All @@ -1574,6 +1597,13 @@ func runServer(v *viper.Viper) {
select {
case <-signalChan:
logger.Info("received signal, shutting down gracefully")
if webPanelServer != nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if err := webPanelServer.Shutdown(ctx); err != nil {
logger.Error("failed to shut down web panel cleanly", zap.Error(err))
}
cancel()
}
if err := s.Close(); err != nil {
logger.Error("failed to shut down server cleanly", zap.Error(err))
}
Expand All @@ -1587,6 +1617,105 @@ func runServer(v *viper.Viper) {
}
}

func startWebPanelServer(configPath string, c *serverConfigWebPanel) (*http.Server, error) {
if c == nil || (!c.Enabled && c.Listen == "") {
return nil, nil
}
if configPath == "" {
return nil, configError{Field: "webPanel", Err: errors.New("config file path is unavailable")}
}
listen := c.Listen
if listen == "" {
listen = "127.0.0.1:8080"
}
handler, err := webpanel.New(webpanel.Config{
ConfigPath: configPath,
Path: c.Path,
Password: c.Password,
Cookie: webpanel.CookieConfig{
Name: c.Cookie.Name,
Value: c.Cookie.Value,
Secure: c.Cookie.Secure,
},
IPWhitelist: c.IPWhitelist,
Validate: validateServerConfigBytes(configPath),
})
if err != nil {
return nil, configError{Field: "webPanel", Err: err}
}
listener, err := correctnet.Listen("tcp", listen)
if err != nil {
return nil, configError{Field: "webPanel.listen", Err: err}
}
srv := &http.Server{Handler: handler}
go func() {
logger.Info("web panel up and running", zap.String("listen", listen), zap.String("path", handlerPath(c.Path)))
if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
logger.Fatal("failed to serve web panel", zap.Error(err))
}
}()
return srv, nil
}

func handlerPath(p string) string {
if p == "" {
return "/panel"
}
if !strings.HasPrefix(p, "/") {
return "/" + p
}
return p
}

func validateServerConfigBytes(configPath string) func([]byte) error {
return func(data []byte) error {
v := viper.New()
v.SetConfigType(configTypeFromPath(configPath))
if err := v.ReadConfig(bytes.NewReader(data)); err != nil {
return err
}
var config serverConfig
if err := v.Unmarshal(&config); err != nil {
return err
}
return validateWebPanelConfig(configPath, config.WebPanel)
}
}

func validateWebPanelConfig(configPath string, c *serverConfigWebPanel) error {
if c == nil || (!c.Enabled && c.Listen == "") {
return nil
}
_, err := webpanel.New(webpanel.Config{
ConfigPath: configPath,
Path: c.Path,
Password: c.Password,
Cookie: webpanel.CookieConfig{
Name: c.Cookie.Name,
Value: c.Cookie.Value,
Secure: c.Cookie.Secure,
},
IPWhitelist: c.IPWhitelist,
Validate: func([]byte) error { return nil },
})
if err != nil {
return configError{Field: "webPanel", Err: err}
}
return nil
}

func configTypeFromPath(configPath string) string {
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(configPath)), ".")
switch ext {
case "yml":
return "yaml"
case "":
return "yaml"
default:
return ext
}
}

func runTrafficStatsServer(listen string, handler http.Handler) {
logger.Info("traffic stats server up and running", zap.String("listen", listen))
if err := correctnet.HTTPListenAndServe(listen, handler); err != nil {
Expand Down
12 changes: 12 additions & 0 deletions app/cmd/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,18 @@ func TestServerConfig(t *testing.T) {
ListenHTTPS: ":443",
ForceHTTPS: true,
},
WebPanel: &serverConfigWebPanel{
Enabled: true,
Listen: "127.0.0.1:18090",
Path: "/secret-panel",
Password: "panel_password",
Cookie: serverConfigWebPanelCookie{
Name: "hy_panel",
Value: "cookie_secret",
Secure: true,
},
IPWhitelist: []string{"127.0.0.1", "10.0.0.0/8"},
},
})
}

Expand Down
13 changes: 13 additions & 0 deletions app/cmd/server_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,16 @@ masquerade:
listenHTTP: :80
listenHTTPS: :443
forceHTTPS: true

webPanel:
enabled: true
listen: 127.0.0.1:18090
path: /secret-panel
password: panel_password
cookie:
name: hy_panel
value: cookie_secret
secure: true
ipWhitelist:
- 127.0.0.1
- 10.0.0.0/8
2 changes: 1 addition & 1 deletion app/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ require (
github.com/stretchr/testify v1.11.1
github.com/txthinking/socks5 v0.0.0-20230325130024-4230056ae301
go.uber.org/zap v1.24.0
go.yaml.in/yaml/v3 v3.0.4
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842
golang.org/x/sync v0.19.0
golang.org/x/sys v0.41.0
Expand Down Expand Up @@ -72,7 +73,6 @@ require (
github.com/wlynxg/anet v0.0.5 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/mod v0.32.0 // indirect
Expand Down
Loading