diff --git a/handler/routes.go b/handler/routes.go index 51127c3..20d99c4 100644 --- a/handler/routes.go +++ b/handler/routes.go @@ -1117,6 +1117,12 @@ func GlobalSettingSubmit(db store.IStore) echo.HandlerFunc { return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Invalid DNS server address"}) } + // validate the config file permissions (octal, e.g. 0600) + if _, err := util.ParseConfigFileMode(globalSettings.ConfigFileMode); err != nil { + log.Warnf("Invalid config file mode input from user: %v", globalSettings.ConfigFileMode) + return c.JSON(http.StatusBadRequest, jsonHTTPResponse{false, "Config file permissions must be octal, e.g. 0600"}) + } + globalSettings.UpdatedAt = time.Now().UTC() // write config to the database diff --git a/model/setting.go b/model/setting.go index 4810b70..f019c9f 100644 --- a/model/setting.go +++ b/model/setting.go @@ -13,6 +13,7 @@ type GlobalSetting struct { FirewallMark string `json:"firewall_mark"` Table string `json:"table"` ConfigFilePath string `json:"config_file_path"` + ConfigFileMode string `json:"config_file_mode"` MaxmindLicenseKey string `json:"maxmind_license_key"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/store/jsondb/jsondb.go b/store/jsondb/jsondb.go index 162e001..1013f20 100644 --- a/store/jsondb/jsondb.go +++ b/store/jsondb/jsondb.go @@ -111,6 +111,7 @@ func (o *JsonDB) Init() error { globalSetting.FirewallMark = util.LookupEnvOrString(util.FirewallMarkEnvVar, util.DefaultFirewallMark) globalSetting.Table = util.LookupEnvOrString(util.TableEnvVar, util.DefaultTable) globalSetting.ConfigFilePath = util.LookupEnvOrString(util.ConfigFilePathEnvVar, util.DefaultConfigFilePath) + globalSetting.ConfigFileMode = util.LookupEnvOrString(util.ConfigFileModeEnvVar, util.DefaultConfigFileMode) globalSetting.MaxmindLicenseKey = util.LookupEnvOrString(util.MaxmindLicenseKeyEnvVar, "") globalSetting.UpdatedAt = time.Now().UTC() o.conn.Write("server", "global_settings", globalSetting) diff --git a/templates/global_settings.html b/templates/global_settings.html index 46d73ea..cb33d25 100644 --- a/templates/global_settings.html +++ b/templates/global_settings.html @@ -73,6 +73,12 @@ Global Settings name="config_file_path" placeholder="E.g. /etc/wireguard/wg0.conf" value="{{ .globalSettings.ConfigFilePath }}"> +
0600 (owner read/write only). Leave blank to use the default.maxmind.com to generate one. Click
Install / Update to download it locally; it also auto-updates on startup
@@ -185,6 +194,7 @@ Global Settings
"firewall_mark": $("#firewall_mark").val(),
"table": $("#table").val(),
"config_file_path": $("#config_file_path").val(),
+ "config_file_mode": $("#config_file_mode").val(),
"maxmind_license_key": $("#maxmind_license_key").val()
};
}
diff --git a/util/config.go b/util/config.go
index 49f22c9..6d2e6ab 100644
--- a/util/config.go
+++ b/util/config.go
@@ -46,6 +46,7 @@ const (
DefaultFirewallMark = "0xca6c" // i.e. 51820
DefaultTable = "auto"
DefaultConfigFilePath = "/etc/wireguard/wg0.conf"
+ DefaultConfigFileMode = "0600"
DefaultGeoLite2DBPath = "./db/GeoLite2-City.mmdb"
UsernameEnvVar = "WGUI_USERNAME"
PasswordEnvVar = "WGUI_PASSWORD"
@@ -60,6 +61,7 @@ const (
FirewallMarkEnvVar = "WGUI_FIREWALL_MARK"
TableEnvVar = "WGUI_TABLE"
ConfigFilePathEnvVar = "WGUI_CONFIG_FILE_PATH"
+ ConfigFileModeEnvVar = "WGUI_CONFIG_FILE_MODE"
LogLevel = "WGUI_LOG_LEVEL"
ServerAddressesEnvVar = "WGUI_SERVER_INTERFACE_ADDRESSES"
ServerListenPortEnvVar = "WGUI_SERVER_LISTEN_PORT"
diff --git a/util/util.go b/util/util.go
index c466377..97158b2 100644
--- a/util/util.go
+++ b/util/util.go
@@ -541,6 +541,26 @@ func GetSubnetRangesString() string {
return strings.TrimSpace(strB.String())
}
+// configFileFallbackMode is applied to the generated WireGuard config file when
+// no valid mode is configured. The file holds private keys, so it defaults to
+// owner read/write only.
+const configFileFallbackMode os.FileMode = 0600
+
+// ParseConfigFileMode parses an octal file-mode string (e.g. "0600"). An empty
+// string yields the default 0600. A value that is not valid octal is an error.
+// Only the permission bits are kept.
+func ParseConfigFileMode(mode string) (os.FileMode, error) {
+ mode = strings.TrimSpace(mode)
+ if mode == "" {
+ return configFileFallbackMode, nil
+ }
+ parsed, err := strconv.ParseUint(mode, 8, 32)
+ if err != nil {
+ return 0, fmt.Errorf("invalid file mode %q: must be octal, e.g. 0600", mode)
+ }
+ return os.FileMode(parsed) & os.ModePerm, nil
+}
+
// WriteWireGuardServerConfig to write WireGuard server config. e.g. wg0.conf
func WriteWireGuardServerConfig(tmplDir fs.FS, serverConfig model.Server, clientDataList []model.ClientData, usersList []model.User, globalSettings model.GlobalSetting) error {
var tmplWireguardConf string
@@ -576,12 +596,6 @@ func WriteWireGuardServerConfig(tmplDir fs.FS, serverConfig model.Server, client
return err
}
- // write config file to disk
- f, err := os.Create(globalSettings.ConfigFilePath)
- if err != nil {
- return err
- }
-
config := map[string]interface{}{
"serverConfig": serverConfig,
"clientDataList": escapedClientDataList,
@@ -589,13 +603,34 @@ func WriteWireGuardServerConfig(tmplDir fs.FS, serverConfig model.Server, client
"usersList": usersList,
}
- err = t.Execute(f, config)
+ // The config file contains private keys, so restrict its permissions.
+ // Default to 0600 when unset or invalid.
+ mode, err := ParseConfigFileMode(globalSettings.ConfigFileMode)
+ if err != nil {
+ log.Warnf("%v; falling back to 0600 for %s", err, globalSettings.ConfigFilePath)
+ mode = configFileFallbackMode
+ }
+
+ // Write in place (rather than via a temp file + rename) so that a plain
+ // IN_CLOSE_WRITE is emitted on the config path. The container entrypoint's
+ // inotify watch (WGUI_MANAGE_RESTART) relies on this to restart WireGuard.
+ f, err := os.OpenFile(globalSettings.ConfigFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
if err != nil {
return err
}
- f.Close()
+ // Enforce the exact permissions regardless of the process umask or of a
+ // pre-existing file's mode (OpenFile does not change an existing file).
+ if err := os.Chmod(globalSettings.ConfigFilePath, mode); err != nil {
+ f.Close()
+ return err
+ }
- return nil
+ if err := t.Execute(f, config); err != nil {
+ f.Close()
+ return err
+ }
+
+ return f.Close()
}
// SendRequestedConfigsToTelegram to send client all their configs. Returns failed configs list.
diff --git a/util/util_test.go b/util/util_test.go
new file mode 100644
index 0000000..050c2da
--- /dev/null
+++ b/util/util_test.go
@@ -0,0 +1,89 @@
+package util
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "testing/fstest"
+
+ "github.com/ngoduykhanh/wireguard-ui/model"
+)
+
+func TestParseConfigFileMode(t *testing.T) {
+ cases := []struct {
+ in string
+ want os.FileMode
+ wantErr bool
+ }{
+ {"", 0600, false},
+ {" ", 0600, false},
+ {"0600", 0600, false},
+ {"600", 0600, false},
+ {"0640", 0640, false},
+ {"0644", 0644, false},
+ {"0777", 0777, false},
+ {"nope", 0, true},
+ {"0x600", 0, true},
+ {"999", 0, true}, // 9 is not a valid octal digit
+ }
+ for _, c := range cases {
+ got, err := ParseConfigFileMode(c.in)
+ if c.wantErr {
+ if err == nil {
+ t.Errorf("ParseConfigFileMode(%q) expected error, got mode %o", c.in, got)
+ }
+ continue
+ }
+ if err != nil {
+ t.Errorf("ParseConfigFileMode(%q) unexpected error: %v", c.in, err)
+ continue
+ }
+ if got != c.want {
+ t.Errorf("ParseConfigFileMode(%q) = %o, want %o", c.in, got, c.want)
+ }
+ }
+}
+
+func testServer() model.Server {
+ return model.Server{
+ KeyPair: &model.ServerKeypair{PrivateKey: "priv", PublicKey: "pub"},
+ Interface: &model.ServerInterface{Addresses: []string{"10.0.0.1/24"}, ListenPort: 51820},
+ }
+}
+
+// minimal template that exercises the fields WriteWireGuardServerConfig injects
+const testTmpl = "[Interface]\nListenPort = {{ .serverConfig.Interface.ListenPort }}\nPrivateKey = {{ .serverConfig.KeyPair.PrivateKey }}\n"
+
+func TestWriteWireGuardServerConfigMode(t *testing.T) {
+ tmplDir := fstest.MapFS{"wg.conf": &fstest.MapFile{Data: []byte(testTmpl)}}
+ dir := t.TempDir()
+ cfgPath := filepath.Join(dir, "wg0.conf")
+
+ // default (empty) mode must produce a 0600 file
+ settings := model.GlobalSetting{ConfigFilePath: cfgPath}
+ if err := WriteWireGuardServerConfig(tmplDir, testServer(), nil, nil, settings); err != nil {
+ t.Fatalf("write failed: %v", err)
+ }
+ fi, err := os.Stat(cfgPath)
+ if err != nil {
+ t.Fatalf("stat failed: %v", err)
+ }
+ if fi.Mode().Perm() != 0600 {
+ t.Errorf("default mode = %o, want 0600", fi.Mode().Perm())
+ }
+ data, _ := os.ReadFile(cfgPath)
+ if !strings.Contains(string(data), "ListenPort = 51820") {
+ t.Errorf("rendered config missing expected content: %q", string(data))
+ }
+
+ // custom mode must be honored, even when overwriting an existing file
+ settings.ConfigFileMode = "0640"
+ if err := WriteWireGuardServerConfig(tmplDir, testServer(), nil, nil, settings); err != nil {
+ t.Fatalf("write failed: %v", err)
+ }
+ fi, _ = os.Stat(cfgPath)
+ if fi.Mode().Perm() != 0640 {
+ t.Errorf("custom mode = %o, want 0640", fi.Mode().Perm())
+ }
+}