From 06b3df4586a3717ee0c7f147a2877b5d11d3bc81 Mon Sep 17 00:00:00 2001 From: Ioannis Dressos <96877388+idressos@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:30:17 +0300 Subject: [PATCH] Add configurable config file permissions (default 0600) The generated WireGuard config contains the server private key and client preshared keys, so it is now written with mode 0600 by default instead of inheriting a umask-dependent (often world-readable) mode. The mode is configurable via the global settings UI or WGUI_CONFIG_FILE_MODE. The file is still written in place so the container inotify restart watch keeps working. --- handler/routes.go | 6 +++ model/setting.go | 1 + store/jsondb/jsondb.go | 1 + templates/global_settings.html | 12 ++++- util/config.go | 2 + util/util.go | 53 ++++++++++++++++---- util/util_test.go | 89 ++++++++++++++++++++++++++++++++++ 7 files changed, 154 insertions(+), 10 deletions(-) create mode 100644 util/util_test.go 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 }}"> +
+ + +
7. WireGuard Config File Path
The path of your WireGuard server config file. Please make sure the parent directory exists and is writable.
-
8. MaxMind License Key
+
8. Config File Permissions
+
Octal file mode applied to the generated config file, which contains private keys. + Default value: 0600 (owner read/write only). Leave blank to use the default.
+
9. MaxMind License Key
Your MaxMind license key, used to download the GeoLite2-City database. Create a free account at 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()) + } +}