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:
parent
b393e2e3a3
commit
06b3df4586
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
53
util/util.go
53
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.
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue