diff --git a/README.md b/README.md index a6dad96..6aad5cb 100644 --- a/README.md +++ b/README.md @@ -216,6 +216,25 @@ Here is all the options of a configuration file: // Form fields and values, you have to manually validate the correctness // of the field value + // + // Values in Meta are scheme enabled, and supports following scheme + // prefixes: + // - "literal://": Text literal (Default) + // Example: literal://Data value + // (The final value will be "Data value") + // Example: literal://file:///tmp/afile + // (The final value will be "file:///tmp/afile") + // - "file://": Load Meta value from given file. + // Example: file:///home/user/.ssh/private_key + // (The file path is /home/user/.ssh/private_key) + // - "enviroment://": Load Meta value from an Enviroment Variable. + // Example: enviroment://PRIVATE_KEY_DATA + // (The name of the target enviroment variable is + // PRIVATE_KEY_DATA) + // + // All data in Meta is loaded during start up, and will not be updated + // even the source already been modified. + // "Meta": { // Data for predefined User field "User": "pre-defined-username", @@ -229,7 +248,7 @@ Here is all the options of a configuration file: // Data for predefined Private Key field, should contains the content // of a Key file - "Private Key": "-----BEGIN RSA PRIV...\nMIIE...\n-----END RSA PRI...\n", + "Private Key": "file:///home/user/.ssh/private_key", // Data for predefined Authentication field. Valid values is what // displayed on the page (Password, Private Key, None) diff --git a/application/configuration/common.go b/application/configuration/common.go new file mode 100644 index 0000000..9cdcad5 --- /dev/null +++ b/application/configuration/common.go @@ -0,0 +1,9 @@ +package configuration + +func durationAtLeast(current, min int) int { + if current > min { + return current + } + + return min +} diff --git a/application/configuration/config.go b/application/configuration/config.go index be6c402..a095b24 100644 --- a/application/configuration/config.go +++ b/application/configuration/config.go @@ -120,6 +120,27 @@ func (s Server) Verify() error { return nil } +// Meta contains data of a Key -> Value map which can be use to store +// dynamically structured configuration options +type Meta map[string]String + +// Concretize returns an concretized Meta as a `map[string]string` +func (m Meta) Concretize() (map[string]string, error) { + mm := make(map[string]string, len(m)) + + for k, v := range m { + result, err := v.Parse() + + if err != nil { + return nil, fmt.Errorf("Unable to parse Meta \"%s\": %s", k, err) + } + + mm[k] = result + } + + return mm, nil +} + // Preset contains data of a static remote host type Preset struct { Title string diff --git a/application/configuration/loader_enviro.go b/application/configuration/loader_enviro.go index b706012..b97ad6b 100644 --- a/application/configuration/loader_enviro.go +++ b/application/configuration/loader_enviro.go @@ -32,7 +32,7 @@ const ( enviroTypeName = "Environment Variable" ) -func parseEviro(name string) string { +func parseEnv(name string) string { v := os.Getenv(name) if !strings.HasPrefix(v, "SSHWIFTY_ENV_RENAMED:") { @@ -42,25 +42,35 @@ func parseEviro(name string) string { return os.Getenv(v[21:]) } +func parseEnvDef(name string, def string) string { + v := parseEnv(name) + + if len(v) > 0 { + return v + } + + return def +} + // Enviro creates an environment variable based configuration loader func Enviro() Loader { return func(log log.Logger) (string, Configuration, error) { log.Info("Loading configuration from environment variables ...") dialTimeout, _ := strconv.ParseUint( - parseEviro("SSHWIFTY_DIALTIMEOUT"), 10, 32) + parseEnv("SSHWIFTY_DIALTIMEOUT"), 10, 32) cfg, cfgErr := fileCfgCommon{ - HostName: parseEviro("SSHWIFTY_HOSTNAME"), - SharedKey: parseEviro("SSHWIFTY_SHAREDKEY"), + HostName: parseEnv("SSHWIFTY_HOSTNAME"), + SharedKey: parseEnv("SSHWIFTY_SHAREDKEY"), DialTimeout: int(dialTimeout), - Socks5: parseEviro("SSHWIFTY_SOCKS5"), - Socks5User: parseEviro("SSHWIFTY_SOCKS5_USER"), - Socks5Password: parseEviro("SSHWIFTY_SOCKS5_PASSWORD"), + Socks5: parseEnv("SSHWIFTY_SOCKS5"), + Socks5User: parseEnv("SSHWIFTY_SOCKS5_USER"), + Socks5Password: parseEnv("SSHWIFTY_SOCKS5_PASSWORD"), Servers: nil, Presets: nil, OnlyAllowPresetRemotes: len( - parseEviro("SSHWIFTY_ONLYALLOWPRESETREMOTES")) > 0, + parseEnv("SSHWIFTY_ONLYALLOWPRESETREMOTES")) > 0, }.build() if cfgErr != nil { @@ -68,32 +78,28 @@ func Enviro() Loader { "Failed to build the configuration: %s", cfgErr) } - listenIface := parseEviro("SSHWIFTY_LISTENINTERFACE") - - if len(listenIface) <= 0 { - listenIface = "127.0.0.1" - } + listenIface := parseEnv("SSHWIFTY_LISTENINTERFACE") listenPort, _ := strconv.ParseUint( - parseEviro("SSHWIFTY_LISTENPORT"), 10, 16) + parseEnv("SSHWIFTY_LISTENPORT"), 10, 16) initialTimeout, _ := strconv.ParseUint( - parseEviro("SSHWIFTY_INITIALTIMEOUT"), 10, 32) + parseEnv("SSHWIFTY_INITIALTIMEOUT"), 10, 32) readTimeout, _ := strconv.ParseUint( - parseEviro("SSHWIFTY_READTIMEOUT"), 10, 32) + parseEnv("SSHWIFTY_READTIMEOUT"), 10, 32) writeTimeout, _ := strconv.ParseUint( - parseEviro("SSHWIFTY_WRITETIMEOUT"), 10, 32) + parseEnv("SSHWIFTY_WRITETIMEOUT"), 10, 32) heartbeatTimeout, _ := strconv.ParseUint( - parseEviro("SSHWIFTY_HEARTBEATTIMEOUT"), 10, 32) + parseEnv("SSHWIFTY_HEARTBEATTIMEOUT"), 10, 32) readDelay, _ := strconv.ParseUint( - parseEviro("SSHWIFTY_READDELAY"), 10, 32) + parseEnv("SSHWIFTY_READDELAY"), 10, 32) writeDelay, _ := strconv.ParseUint( - parseEviro("SSHWIFTY_WRITEELAY"), 10, 32) + parseEnv("SSHWIFTY_WRITEELAY"), 10, 32) cfgSer := fileCfgServer{ ListenInterface: listenIface, @@ -104,12 +110,12 @@ func Enviro() Loader { HeartbeatTimeout: int(heartbeatTimeout), ReadDelay: int(readDelay), WriteDelay: int(writeDelay), - TLSCertificateFile: parseEviro("SSHWIFTY_TLSCERTIFICATEFILE"), - TLSCertificateKeyFile: parseEviro("SSHWIFTY_TLSCERTIFICATEKEYFILE"), + TLSCertificateFile: parseEnv("SSHWIFTY_TLSCERTIFICATEFILE"), + TLSCertificateKeyFile: parseEnv("SSHWIFTY_TLSCERTIFICATEKEYFILE"), } - presets := make([]Preset, 0, 16) - presetStr := strings.TrimSpace(parseEviro("SSHWIFTY_PRESETS")) + presets := make(fileCfgPresets, 0, 16) + presetStr := strings.TrimSpace(parseEnv("SSHWIFTY_PRESETS")) if len(presetStr) > 0 { jErr := json.Unmarshal([]byte(presetStr), &presets) @@ -120,6 +126,13 @@ func Enviro() Loader { } } + concretizePresets, err := presets.concretize() + + if err != nil { + return enviroTypeName, Configuration{}, fmt.Errorf( + "Unable to parse Preset data: %s", err) + } + return enviroTypeName, Configuration{ HostName: cfg.HostName, SharedKey: cfg.SharedKey, @@ -128,7 +141,7 @@ func Enviro() Loader { Socks5User: cfg.Socks5User, Socks5Password: cfg.Socks5Password, Servers: []Server{cfgSer.build()}, - Presets: presets, + Presets: concretizePresets, OnlyAllowPresetRemotes: cfg.OnlyAllowPresetRemotes, }, nil } diff --git a/application/configuration/loader_file.go b/application/configuration/loader_file.go index 2717da9..0f0f4f3 100644 --- a/application/configuration/loader_file.go +++ b/application/configuration/loader_file.go @@ -47,30 +47,28 @@ type fileCfgServer struct { TLSCertificateKeyFile string // Location of TLS certificate key } -func (f fileCfgServer) durationAtLeast(current, min int) int { - if current > min { - return current +func (f *fileCfgServer) build() Server { + iface := f.ListenInterface + + if len(iface) <= 0 { + iface = "127.0.0.1" } - return min -} - -func (f *fileCfgServer) build() Server { return Server{ - ListenInterface: f.ListenInterface, + ListenInterface: iface, ListenPort: f.ListenPort, InitialTimeout: time.Duration( - f.durationAtLeast(f.InitialTimeout, 5)) * time.Second, + durationAtLeast(f.InitialTimeout, 5)) * time.Second, ReadTimeout: time.Duration( - f.durationAtLeast(f.ReadTimeout, 30)) * time.Second, + durationAtLeast(f.ReadTimeout, 30)) * time.Second, WriteTimeout: time.Duration( - f.durationAtLeast(f.WriteTimeout, 30)) * time.Second, + durationAtLeast(f.WriteTimeout, 30)) * time.Second, HeartbeatTimeout: time.Duration( - f.durationAtLeast(f.HeartbeatTimeout, 10)) * time.Second, + durationAtLeast(f.HeartbeatTimeout, 10)) * time.Second, ReadDelay: time.Duration( - f.durationAtLeast(f.ReadDelay, 0)) * time.Millisecond, + durationAtLeast(f.ReadDelay, 0)) * time.Millisecond, WriteDelay: time.Duration( - f.durationAtLeast(f.WriteDelay, 0)) * time.Millisecond, + durationAtLeast(f.WriteDelay, 0)) * time.Millisecond, TLSCertificateFile: f.TLSCertificateFile, TLSCertificateKeyFile: f.TLSCertificateKeyFile, } @@ -80,16 +78,42 @@ type fileCfgPreset struct { Title string Type string Host string - Meta map[string]string + Meta Meta } -func (f fileCfgPreset) build() Preset { +func (f fileCfgPreset) concretize() (Preset, error) { + m, err := f.Meta.Concretize() + + if err != nil { + return Preset{}, err + } + return Preset{ Title: f.Title, Type: strings.TrimSpace(f.Type), Host: f.Host, - Meta: f.Meta, + Meta: m, + }, nil +} + +type fileCfgPresets []fileCfgPreset + +func (f fileCfgPresets) concretize() ([]Preset, error) { + ps := make([]Preset, 0, len(f)) + + for i, p := range f { + pp, err := p.concretize() + + if err != nil { + return nil, fmt.Errorf( + "Unable to concretize Preset %d (titled \"%s\"): %s", + i+1, p.Title, err) + } + + ps = append(ps, pp) } + + return ps, nil } type fileCfgCommon struct { @@ -115,7 +139,7 @@ type fileCfgCommon struct { Servers []*fileCfgServer // Remotes - Presets []*fileCfgPreset + Presets fileCfgPresets // Allow predefined remotes only OnlyAllowPresetRemotes bool @@ -125,7 +149,7 @@ func (f fileCfgCommon) build() (fileCfgCommon, error) { return fileCfgCommon{ HostName: f.HostName, SharedKey: f.SharedKey, - DialTimeout: f.DialTimeout, + DialTimeout: durationAtLeast(f.DialTimeout, 5), Socks5: f.Socks5, Socks5User: f.Socks5User, Socks5Password: f.Socks5Password, @@ -165,10 +189,10 @@ func loadFile(filePath string) (string, Configuration, error) { servers[i] = finalCfg.Servers[i].build() } - presets := make([]Preset, len(finalCfg.Presets)) + presets, err := finalCfg.Presets.concretize() - for i := range presets { - presets[i] = finalCfg.Presets[i].build() + if err != nil { + return fileTypeName, Configuration{}, err } return fileTypeName, Configuration{ diff --git a/application/configuration/string.go b/application/configuration/string.go new file mode 100644 index 0000000..2453744 --- /dev/null +++ b/application/configuration/string.go @@ -0,0 +1,60 @@ +package configuration + +import ( + "fmt" + "io/ioutil" + "os" + "path/filepath" + "strings" +) + +// String represents a config string +type String string + +// Parse parses current string and return the parsed result +func (s String) Parse() (string, error) { + ss := string(s) + + sSchemeLeadIdx := strings.Index(ss, "://") + + if sSchemeLeadIdx < 0 { + return ss, nil + } + + sSchemeLeadEnd := sSchemeLeadIdx + 3 + + switch strings.ToLower(ss[:sSchemeLeadIdx]) { + case "file": + fPath, e := filepath.Abs(ss[sSchemeLeadEnd:]) + + if e != nil { + return ss, e + } + + f, e := os.Open(fPath) + + if e != nil { + return "", fmt.Errorf("Unable to open %s: %s", fPath, e) + } + + defer f.Close() + + fData, e := ioutil.ReadAll(f) + + if e != nil { + return "", fmt.Errorf("Unable to read from %s: %s", fPath, e) + } + + return string(fData), nil + + case "enviroment": + return os.Getenv(ss[sSchemeLeadEnd:]), nil + + case "literal": + return ss[sSchemeLeadEnd:], nil + + default: + return "", fmt.Errorf( + "Scheme \"%s\" was unsupported", ss[:sSchemeLeadIdx]) + } +} diff --git a/application/configuration/string_test.go b/application/configuration/string_test.go new file mode 100644 index 0000000..77df360 --- /dev/null +++ b/application/configuration/string_test.go @@ -0,0 +1,77 @@ +package configuration + +import ( + "os" + "testing" +) + +func TestStringString(t *testing.T) { + ss := String("aaaaaaaaaaaaa") + + result, err := ss.Parse() + + if err != nil { + t.Error("Unable to parse:", err) + + return + } + + if result != "aaaaaaaaaaaaa" { + t.Errorf( + "Expecting the result to be %s, got %s instead", + "aaaaaaaaaaaaa", + result, + ) + + return + } +} + +func TestStringFile(t *testing.T) { + const testFilename = "sshwifty.configuration.test.string.file.tmp" + + filePath := os.TempDir() + string(os.PathSeparator) + testFilename + + f, err := os.Create(filePath) + + if err != nil { + t.Error("Unable to create file:", err) + + return + } + + defer os.Remove(filePath) + + f.WriteString("TestAAAA") + f.Close() + + ss := String("file://" + filePath) + + result, err := ss.Parse() + + if err != nil { + t.Error("Unable to parse:", err) + + return + } + + if result != "TestAAAA" { + t.Errorf( + "Expecting the result to be %s, got %s instead", + "TestAAAA", + result, + ) + + return + } + + ss = String("file://" + filePath + ".notexist") + + result, err = ss.Parse() + + if err == nil { + t.Error("Parsing an non-existing file should result an error") + + return + } +}