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.
This commit is contained in:
Ioannis Dressos 2026-07-08 12:30:17 +03:00
parent b393e2e3a3
commit 06b3df4586
No known key found for this signature in database
7 changed files with 154 additions and 10 deletions

View File

@ -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

View File

@ -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"`
}

View File

@ -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)

View File

@ -73,6 +73,12 @@ Global Settings
name="config_file_path" placeholder="E.g. /etc/wireguard/wg0.conf"
value="{{ .globalSettings.ConfigFilePath }}">
</div>
<div class="form-group">
<label for="config_file_mode">Config File Permissions</label>
<input type="text" class="form-control" id="config_file_mode"
name="config_file_mode" placeholder="E.g. 0600"
value="{{ if .globalSettings.ConfigFileMode }}{{ .globalSettings.ConfigFileMode }}{{ else }}0600{{ end }}">
</div>
<div class="form-group">
<label for="maxmind_license_key">MaxMind License Key</label>
<input type="text" class="form-control" id="maxmind_license_key"
@ -131,7 +137,10 @@ Global Settings
<dt>7. WireGuard Config File Path</dt>
<dd>The path of your WireGuard server config file. Please make sure the parent directory
exists and is writable.</dd>
<dt>8. MaxMind License Key</dt>
<dt>8. Config File Permissions</dt>
<dd>Octal file mode applied to the generated config file, which contains private keys.
Default value: <code>0600</code> (owner read/write only). Leave blank to use the default.</dd>
<dt>9. MaxMind License Key</dt>
<dd>Your MaxMind license key, used to download the GeoLite2-City database. Create a free
account at <code>maxmind.com</code> to generate one. Click
<strong>Install / Update</strong> 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()
};
}

View File

@ -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"

View File

@ -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.

89
util/util_test.go Normal file
View File

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