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()) + } +}