git.iko.soy/typeable/oauth2-proxy
on master help sign in
Add config file support
commitd4fe9a4f57c365aafeae4311dfa28549bf60005c authorJehiah Czebotar <jehiah@gmail.com> dateSun 9 Nov 2014, 14:51 (12 years ago) parent899749a
8 files changed+242−117
.travis.yml+2 README.md+21−12 contrib/google_auth_proxy.cfg.example+44 main.go+65−69 oauthproxy.go+38−34 options.go+67 string_array.go+2−2 version.go+3
.travis.yml+2
@@ -2,6 +2,8 @@ language: go
22install:
33 - go get github.com/bmizerany/assert
44 - go get github.com/bitly/go-simplejson
5+ - go get github.com/mreiferson/go-options
6+ - go get github.com/BurntSushi/toml
57notifications:
68 email: false
79
README.md+21−12
@@ -42,28 +42,40 @@ intend to run `google_auth_proxy` on.
42425. Take note of the **Client ID** and **Client Secret**
4343
4444
45−## Command Line Options
45+## Configuration
46+
47+`google_auth_proxy` can be configured via [config file](#config-file), [command line options](#command-line-options) or [environment variables](#environment-variables).
48+
49+### Config File
50+
51+An example [google_auth_proxy.cfg](contrib/google_auth_proxy.cfg.example) config file is in the contrib directory. It can be used by specifying `-config=/etc/google_auth_proxy.cfg`
52+
53+### Command Line Options
4654
4755```
48−Usage of ./google_auth_proxy:
56+Usage of google_auth_proxy:
4957 -authenticated-emails-file="": authenticate against emails via file (one per line)
5058 -client-id="": the Google OAuth Client ID: ie: "123456.apps.googleusercontent.com"
5159 -client-secret="": the OAuth Client Secret
52− -cookie-domain="": an optional cookie domain to force cookies to
53− -cookie-expire=168h: expire timeframe for cookie
60+ -config="": path to config file
61+ -cookie-domain="": an optional cookie domain to force cookies to (ie: .yourcompany.com)
62+ -cookie-expire=168h0m0s: expire timeframe for cookie
5463 -cookie-https-only=false: set HTTPS only cookie
5564 -cookie-secret="": the seed string for secure cookies
56− -google-apps-domain=[]: authenticate against the given google apps domain (may be given multiple times)
65+ -google-apps-domain=: authenticate against the given Google apps domain (may be given multiple times)
5766 -htpasswd-file="": additionally authenticate against a htpasswd file. Entries must be created with "htpasswd -s" for SHA encryption
5867 -http-address="127.0.0.1:4180": <addr>:<port> to listen on for HTTP clients
59− -pass-basic-auth=true: pass HTTP Basic Auth information to upstream
68+ -pass-basic-auth=true: pass HTTP Basic Auth, X-Forwarded-User and X-Forwarded-Email information to upstream
6069 -redirect-url="": the OAuth Redirect URL. ie: "https://internalapp.yourcompany.com/oauth2/callback"
61− -upstream=[]: the http url(s) of the upstream endpoint. If multiple, routing is based on path
70+ -upstream=: the http url(s) of the upstream endpoint. If multiple, routing is based on path
6271 -version=false: print version string
6372```
6473
74+### Environment variables
6575
66−## Example Configuration
76+The environment variables `google_auth_client_id`, `google_auth_secret` and `google_auth_cookie_secret` can be used in place of the corresponding command-line arguments.
77+
78+### Example Nginx Configuration
6779
6880This example has a [Nginx](http://nginx.org/) SSL endpoint proxying to `google_auth_proxy` on port `4180`.
6981`google_auth_proxy` then authenticates requests for an upstream application running on port `8080`. The external
@@ -105,13 +117,10 @@ The command line to run `google_auth_proxy` would look like this:
105117 --client-secret=...
106118```
107119
108−## Environment variables
109−
110−The environment variables `google_auth_client_id`, `google_auth_secret` and `google_auth_cookie_secret` can be used in place of the corresponding command-line arguments.
111120
112121## Endpoint Documentation
113122
114−Google Auth Proxy responds directly to the following endpoints. All other endpoints will be authenticated.
123+Google Auth Proxy responds directly to the following endpoints. All other endpoints will be proxied upstream when authenticated.
115124
116125* /ping - returns an 200 OK response
117126* /oauth2/sign_in - the login page, which also doubles as a sign out page (it clears cookies)
contrib/google_auth_proxy.cfg.examplenew file+44
@@ -0,0 +1,44 @@
1+## Google Auth Proxy Config File
2+## https://github.com/bitly/google_auth_proxy
3+
4+## <addr>:<port> to listen on for HTTP clients
5+# http_address = "127.0.0.1:4180"
6+
7+## the OAuth Redirect URL.
8+# redirect_url = "https://internalapp.yourcompany.com/oauth2/callback"
9+
10+## the http url(s) of the upstream endpoint. If multiple, routing is based on path
11+# upstreams = [
12+# "http://127.0.0.1:8080/"
13+# ]
14+
15+## pass HTTP Basic Auth, X-Forwarded-User and X-Forwarded-Email information to upstream
16+# pass_basic_auth = true
17+
18+## Google Apps Domains to allow authentication for
19+# google_apps_domains = [
20+# "yourcompany.com"
21+# ]
22+
23+
24+## The Google OAuth Client ID, Secret
25+# client_id = "123456.apps.googleusercontent.com"
26+# client_secret = ""
27+
28+## Authenticated Email Addresses File (one email per line)
29+# authenticated_emails_file = ""
30+
31+## Htpasswd File (optional)
32+## Additionally authenticate against a htpasswd file. Entries must be created with "htpasswd -s" for SHA encryption
33+## enabling exposes a username/login signin form
34+# htpasswd_file = ""
35+
36+
37+## Cookie Settings
38+## Secret - the seed string for secure cookies
39+## Domain - optional cookie domain to force cookies to (ie: .yourcompany.com)
40+## Expire - expire timeframe for cookie
41+# cookie_secret = ""
42+# cookie_domain = ""
43+# cookie_expire = "168h"
44+# cookie_https_only = false
main.go+65−69
@@ -6,49 +6,63 @@ import (
66 "log"
77 "net"
88 "net/http"
9− "net/url"
109 "os"
1110 "strings"
1211 "time"
13−)
1412
15−const VERSION = "0.1.0"
16−
17−var (
18− showVersion = flag.Bool("version", false, "print version string")
19− httpAddr = flag.String("http-address", "127.0.0.1:4180", "<addr>:<port> to listen on for HTTP clients")
20− redirectUrl = flag.String("redirect-url", "", "the OAuth Redirect URL. ie: \"https://internalapp.yourcompany.com/oauth2/callback\"")
21− clientID = flag.String("client-id", "", "the Google OAuth Client ID: ie: \"123456.apps.googleusercontent.com\"")
22− clientSecret = flag.String("client-secret", "", "the OAuth Client Secret")
23− passBasicAuth = flag.Bool("pass-basic-auth", true, "pass HTTP Basic Auth information to upstream")
24− htpasswdFile = flag.String("htpasswd-file", "", "additionally authenticate against a htpasswd file. Entries must be created with \"htpasswd -s\" for SHA encryption")
25− cookieSecret = flag.String("cookie-secret", "", "the seed string for secure cookies")
26− cookieDomain = flag.String("cookie-domain", "", "an optional cookie domain to force cookies to")
27− cookieExpire = flag.Duration("cookie-expire", time.Duration(168)*time.Hour, "expire timeframe for cookie")
28− cookieHttpsOnly = flag.Bool("cookie-https-only", false, "set HTTPS only cookie")
29− authenticatedEmailsFile = flag.String("authenticated-emails-file", "", "authenticate against emails via file (one per line)")
30− googleAppsDomains = StringArray{}
31− upstreams = StringArray{}
13+ "github.com/BurntSushi/toml"
14+ "github.com/mreiferson/go-options"
3215)
3316
34−func init() {
35− flag.Var(&googleAppsDomains, "google-apps-domain", "authenticate against the given google apps domain (may be given multiple times)")
36− flag.Var(&upstreams, "upstream", "the http url(s) of the upstream endpoint. If multiple, routing is based on path")
37−}
38−
3917func main() {
18+ flagSet := flag.NewFlagSet("google_auth_proxy", flag.ExitOnError)
19+
20+ googleAppsDomains := StringArray{}
21+ upstreams := StringArray{}
22+
23+ config := flagSet.String("config", "", "path to config file")
24+ showVersion := flagSet.Bool("version", false, "print version string")
25+
26+ flagSet.String("http-address", "127.0.0.1:4180", "<addr>:<port> to listen on for HTTP clients")
27+ flagSet.String("redirect-url", "", "the OAuth Redirect URL. ie: \"https://internalapp.yourcompany.com/oauth2/callback\"")
28+ flagSet.Var(&upstreams, "upstream", "the http url(s) of the upstream endpoint. If multiple, routing is based on path")
29+ flagSet.Bool("pass-basic-auth", true, "pass HTTP Basic Auth, X-Forwarded-User and X-Forwarded-Email information to upstream")
30+
31+ flagSet.Var(&googleAppsDomains, "google-apps-domain", "authenticate against the given Google apps domain (may be given multiple times)")
32+ flagSet.String("client-id", "", "the Google OAuth Client ID: ie: \"123456.apps.googleusercontent.com\"")
33+ flagSet.String("client-secret", "", "the OAuth Client Secret")
34+ flagSet.String("authenticated-emails-file", "", "authenticate against emails via file (one per line)")
35+ flagSet.String("htpasswd-file", "", "additionally authenticate against a htpasswd file. Entries must be created with \"htpasswd -s\" for SHA encryption")
4036
41− flag.Parse()
37+ flagSet.String("cookie-secret", "", "the seed string for secure cookies")
38+ flagSet.String("cookie-domain", "", "an optional cookie domain to force cookies to (ie: .yourcompany.com)")
39+ flagSet.Duration("cookie-expire", time.Duration(168)*time.Hour, "expire timeframe for cookie")
40+ flagSet.Bool("cookie-https-only", false, "set HTTPS only cookie")
41+
42+ flagSet.Parse(os.Args[1:])
43+
44+ opts := NewOptions()
45+
46+ var cfg map[string]interface{}
47+ if *config != "" {
48+ _, err := toml.DecodeFile(*config, &cfg)
49+ if err != nil {
50+ log.Fatalf("ERROR: failed to load config file %s - %s", *config, err)
51+ }
52+ }
53+
54+ options.Resolve(opts, flagSet, cfg)
4255
4356 // Try to use env for secrets if no flag is set
44− if *clientID == "" {
45− *clientID = os.Getenv("google_auth_client_id")
57+ // TODO: better parsing of these values
58+ if opts.ClientID == "" {
59+ opts.ClientID = os.Getenv("google_auth_client_id")
4660 }
47− if *clientSecret == "" {
48− *clientSecret = os.Getenv("google_auth_secret")
61+ if opts.ClientSecret == "" {
62+ opts.ClientSecret = os.Getenv("google_auth_secret")
4963 }
50− if *cookieSecret == "" {
51− *cookieSecret = os.Getenv("google_auth_cookie_secret")
64+ if opts.CookieSecret == "" {
65+ opts.CookieSecret = os.Getenv("google_auth_cookie_secret")
5266 }
5367
5468 if *showVersion {
@@ -56,59 +70,41 @@ func main() {
5670 return
5771 }
5872
59− if len(upstreams) < 1 {
60− log.Fatal("missing --upstream")
61− }
62− if *cookieSecret == "" {
63− log.Fatal("missing --cookie-secret")
64− }
65− if *clientID == "" {
66− log.Fatal("missing --client-id")
67− }
68− if *clientSecret == "" {
69− log.Fatal("missing --client-secret")
70− }
71−
72− var upstreamUrls []*url.URL
73− for _, u := range upstreams {
74− upstreamUrl, err := url.Parse(u)
75− if err != nil {
76− log.Fatalf("error parsing --upstream %s", err.Error())
77− }
78− upstreamUrls = append(upstreamUrls, upstreamUrl)
79− }
80− redirectUrl, err := url.Parse(*redirectUrl)
73+ err := opts.Validate()
8174 if err != nil {
82− log.Fatalf("error parsing --redirect-url %s", err.Error())
75+ log.Printf("%s", err)
76+ os.Exit(1)
8377 }
8478
85− validator := NewValidator(googleAppsDomains, *authenticatedEmailsFile)
86− oauthproxy := NewOauthProxy(upstreamUrls, *clientID, *clientSecret, validator)
87− oauthproxy.SetRedirectUrl(redirectUrl)
88− if len(googleAppsDomains) != 0 && *authenticatedEmailsFile == "" {
89− if len(googleAppsDomains) > 1 {
90− oauthproxy.SignInMessage = fmt.Sprintf("Authenticate using one of the following domains: %v", strings.Join(googleAppsDomains, ", "))
79+ validator := NewValidator(opts.GoogleAppsDomains, opts.AuthenticatedEmailsFile)
80+ oauthproxy := NewOauthProxy(opts, validator)
81+
82+ if len(opts.GoogleAppsDomains) != 0 && opts.AuthenticatedEmailsFile == "" {
83+ if len(opts.GoogleAppsDomains) > 1 {
84+ oauthproxy.SignInMessage = fmt.Sprintf("Authenticate using one of the following domains: %v", strings.Join(opts.GoogleAppsDomains, ", "))
9185 } else {
92− oauthproxy.SignInMessage = fmt.Sprintf("Authenticate using %v", googleAppsDomains[0])
86+ oauthproxy.SignInMessage = fmt.Sprintf("Authenticate using %v", opts.GoogleAppsDomains[0])
9387 }
9488 }
95− if *htpasswdFile != "" {
96− oauthproxy.HtpasswdFile, err = NewHtpasswdFromFile(*htpasswdFile)
89+
90+ if opts.HtpasswdFile != "" {
91+ oauthproxy.HtpasswdFile, err = NewHtpasswdFromFile(opts.HtpasswdFile)
9792 if err != nil {
98− log.Fatalf("FATAL: unable to open %s %s", *htpasswdFile, err.Error())
93+ log.Fatalf("FATAL: unable to open %s %s", opts.HtpasswdFile, err)
9994 }
10095 }
101− listener, err := net.Listen("tcp", *httpAddr)
96+
97+ listener, err := net.Listen("tcp", opts.HttpAddress)
10298 if err != nil {
103− log.Fatalf("FATAL: listen (%s) failed - %s", *httpAddr, err.Error())
99+ log.Fatalf("FATAL: listen (%s) failed - %s", opts.HttpAddress, err)
104100 }
105− log.Printf("listening on %s", *httpAddr)
101+ log.Printf("listening on %s", opts.HttpAddress)
106102
107103 server := &http.Server{Handler: oauthproxy}
108104 err = server.Serve(listener)
109105 if err != nil && !strings.Contains(err.Error(), "use of closed network connection") {
110− log.Printf("ERROR: http.Serve() - %s", err.Error())
106+ log.Printf("ERROR: http.Serve() - %s", err)
111107 }
112108
113− log.Printf("HTTP: closing %s", listener.Addr().String())
109+ log.Printf("HTTP: closing %s", listener.Addr())
114110}
oauthproxy.go+38−34
@@ -22,9 +22,12 @@ const oauthStartPath = "/oauth2/start"
2222const oauthCallbackPath = "/oauth2/callback"
2323
2424type OauthProxy struct {
25− CookieSeed string
26− CookieKey string
27− Validator func(string) bool
25+ CookieSeed string
26+ CookieKey string
27+ CookieDomain string
28+ CookieHttpsOnly bool
29+ CookieExpire time.Duration
30+ Validator func(string) bool
2831
2932 redirectUrl *url.URL // the url to receive requests at
3033 oauthRedemptionUrl *url.URL // endpoint to redeem the code
@@ -35,40 +38,41 @@ type OauthProxy struct {
3538 SignInMessage string
3639 HtpasswdFile *HtpasswdFile
3740 serveMux *http.ServeMux
41+ PassBasicAuth bool
3842}
3943
40−func NewOauthProxy(proxyUrls []*url.URL, clientID string, clientSecret string, validator func(string) bool) *OauthProxy {
44+func NewOauthProxy(opts *Options, validator func(string) bool) *OauthProxy {
4145 login, _ := url.Parse("https://accounts.google.com/o/oauth2/auth")
4246 redeem, _ := url.Parse("https://accounts.google.com/o/oauth2/token")
4347 serveMux := http.NewServeMux()
44− for _, u := range proxyUrls {
48+ for _, u := range opts.proxyUrls {
4549 path := u.Path
46− if len(path) == 0 {
47− path = "/"
48− }
4950 u.Path = ""
50− log.Printf("mapping %s => %s", path, u)
51+ log.Printf("mapping path %q => upstream %q", path, u)
5152 serveMux.Handle(path, httputil.NewSingleHostReverseProxy(u))
5253 }
53− return &OauthProxy{
54− CookieKey: "_oauthproxy",
55− CookieSeed: *cookieSecret,
56− Validator: validator,
54+ redirectUrl := opts.redirectUrl
55+ redirectUrl.Path = oauthCallbackPath
5756
58− clientID: clientID,
59− clientSecret: clientSecret,
57+ return &OauthProxy{
58+ CookieKey: "_oauthproxy",
59+ CookieSeed: opts.CookieSecret,
60+ CookieDomain: opts.CookieDomain,
61+ CookieHttpsOnly: opts.CookieHttpsOnly,
62+ CookieExpire: opts.CookieExpire,
63+ Validator: validator,
64+
65+ clientID: opts.ClientID,
66+ clientSecret: opts.ClientSecret,
6067 oauthScope: "profile email",
6168 oauthRedemptionUrl: redeem,
6269 oauthLoginUrl: login,
6370 serveMux: serveMux,
71+ redirectUrl: redirectUrl,
72+ PassBasicAuth: opts.PassBasicAuth,
6473 }
6574}
6675
67−func (p *OauthProxy) SetRedirectUrl(redirectUrl *url.URL) {
68− redirectUrl.Path = oauthCallbackPath
69− p.redirectUrl = redirectUrl
70−}
71−
7276func (p *OauthProxy) GetLoginURL(redirectUrl string) string {
7377 params := url.Values{}
7478 params.Add("redirect_uri", p.redirectUrl.String())
@@ -164,8 +168,8 @@ func jwtDecodeSegment(seg string) ([]byte, error) {
164168
165169func (p *OauthProxy) ClearCookie(rw http.ResponseWriter, req *http.Request) {
166170 domain := strings.Split(req.Host, ":")[0]
167− if *cookieDomain != "" && strings.HasSuffix(domain, *cookieDomain) {
168− domain = *cookieDomain
171+ if p.CookieDomain != "" && strings.HasSuffix(domain, p.CookieDomain) {
172+ domain = p.CookieDomain
169173 }
170174 cookie := &http.Cookie{
171175 Name: p.CookieKey,
@@ -181,8 +185,8 @@ func (p *OauthProxy) ClearCookie(rw http.ResponseWriter, req *http.Request) {
181185func (p *OauthProxy) SetCookie(rw http.ResponseWriter, req *http.Request, val string) {
182186
183187 domain := strings.Split(req.Host, ":")[0] // strip the port (if any)
184− if *cookieDomain != "" && strings.HasSuffix(domain, *cookieDomain) {
185− domain = *cookieDomain
188+ if p.CookieDomain != "" && strings.HasSuffix(domain, p.CookieDomain) {
189+ domain = p.CookieDomain
186190 }
187191 cookie := &http.Cookie{
188192 Name: p.CookieKey,
@@ -190,8 +194,8 @@ func (p *OauthProxy) SetCookie(rw http.ResponseWriter, req *http.Request, val st
190194 Path: "/",
191195 Domain: domain,
192196 HttpOnly: true,
193− Secure: *cookieHttpsOnly,
194− Expires: time.Now().Add(*cookieExpire),
197+ Secure: p.CookieHttpsOnly,
198+ Expires: time.Now().Add(p.CookieExpire),
195199 }
196200 http.SetCookie(rw, cookie)
197201}
@@ -267,11 +271,11 @@ func (p *OauthProxy) GetRedirect(req *http.Request) (string, error) {
267271
268272func (p *OauthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
269273 // check if this is a redirect back at the end of oauth
270− remoteIP := req.Header.Get("X-Real-IP")
271− if remoteIP == "" {
272− remoteIP = req.RemoteAddr
274+ remoteAddr := req.RemoteAddr
275+ if req.Header.Get("X-Real-IP") != "" {
276+ remoteAddr += fmt.Sprintf(" (%q)", req.Header.Get("X-Real-IP"))
273277 }
274− log.Printf("%s %s %s", remoteIP, req.Method, req.URL.Path)
278+ log.Printf("%s %s %s", remoteAddr, req.Method, req.URL.RequestURI())
275279
276280 var ok bool
277281 var user string
@@ -322,7 +326,7 @@ func (p *OauthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
322326
323327 _, email, err := p.redeemCode(req.Form.Get("code"))
324328 if err != nil {
325− log.Printf("error redeeming code %s", err)
329+ log.Printf("%s error redeeming code %s", remoteAddr, err)
326330 p.ErrorPage(rw, 500, "Internal Error", err.Error())
327331 return
328332 }
@@ -334,7 +338,7 @@ func (p *OauthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
334338
335339 // set cookie, or deny
336340 if p.Validator(email) {
337− log.Printf("authenticating %s completed", email)
341+ log.Printf("%s authenticating %s completed", remoteAddr, email)
338342 p.SetCookie(rw, req, email)
339343 http.Redirect(rw, req, redirect, 302)
340344 return
@@ -362,13 +366,13 @@ func (p *OauthProxy) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
362366 }
363367
364368 if !ok {
365− log.Printf("invalid cookie")
369+ log.Printf("%s - invalid cookie session", remoteAddr)
366370 p.SignInPage(rw, req, 403)
367371 return
368372 }
369373
370374 // At this point, the user is authenticated. proxy normally
371− if *passBasicAuth {
375+ if p.PassBasicAuth {
372376 req.SetBasicAuth(user, "")
373377 req.Header["X-Forwarded-User"] = []string{user}
374378 req.Header["X-Forwarded-Email"] = []string{email}
options.gonew file+67
@@ -0,0 +1,67 @@
1+package main
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "net/url"
7+ "time"
8+)
9+
10+// Configuration Options that can be set by Command Line Flag, or Config File
11+type Options struct {
12+ HttpAddress string `flag:"http-address" cfg:"http_address"`
13+ RedirectUrl string `flag:"redirect-url" cfg:"redirect_url"`
14+ ClientID string `flag:"client-id" cfg:"client_id"`
15+ ClientSecret string `flag:"client-secret" cfg:"client_secret"`
16+ PassBasicAuth bool `flag:"pass-basic-auth" cfg:"pass_basic_auth"`
17+ HtpasswdFile string `flag:"htpasswd-file" cfg:"htpasswd_file"`
18+ CookieSecret string `flag:"cookie-secret" cfg:"cookie_secret"`
19+ CookieDomain string `flag:"cookie-domain" cfg:"cookie_domain"`
20+ CookieExpire time.Duration `flag:"cookie-expire" cfg:"cookie_expire"`
21+ CookieHttpsOnly bool `flag:"cookie-https-only" cfg:"cookie_https_only"`
22+ AuthenticatedEmailsFile string `flag:"authenticated-emails-file" cfg:"authenticated_emails_file"`
23+ GoogleAppsDomains []string `flag:"google-apps-domain" cfg:"google_apps_domains"`
24+ Upstreams []string `flag:"upstream" cfg:"upstreams"`
25+
26+ // internal values that are set after config validation
27+ redirectUrl *url.URL
28+ proxyUrls []*url.URL
29+}
30+
31+func NewOptions() *Options {
32+ return &Options{}
33+}
34+
35+func (o *Options) Validate() error {
36+ if len(o.Upstreams) < 1 {
37+ return errors.New("missing -upstream")
38+ }
39+ if o.CookieSecret == "" {
40+ errors.New("missing -cookie-secret")
41+ }
42+ if o.ClientID == "" {
43+ return errors.New("missing -client-id")
44+ }
45+ if o.ClientSecret == "" {
46+ return errors.New("missing -client-secret")
47+ }
48+
49+ redirectUrl, err := url.Parse(o.RedirectUrl)
50+ if err != nil {
51+ return fmt.Errorf("error parsing -redirect-url=%q %s", o.RedirectUrl, err)
52+ }
53+ o.redirectUrl = redirectUrl
54+
55+ for _, u := range o.Upstreams {
56+ upstreamUrl, err := url.Parse(u)
57+ if err != nil {
58+ return fmt.Errorf("error parsing -upstream=%q %s", upstreamUrl, err)
59+ }
60+ if upstreamUrl.Path == "" {
61+ upstreamUrl.Path = "/"
62+ }
63+ o.proxyUrls = append(o.proxyUrls, upstreamUrl)
64+ }
65+
66+ return nil
67+}
string_array.go+2−2
@@ -1,7 +1,7 @@
11package main
22
33import (
4− "fmt"
4+ "strings"
55)
66
77type StringArray []string
@@ -12,5 +12,5 @@ func (a *StringArray) Set(s string) error {
1212}
1313
1414func (a *StringArray) String() string {
15− return fmt.Sprint(*a)
15+ return strings.Join(*a, ",")
1616}
version.gonew file+3
@@ -0,0 +1,3 @@
1+package main
2+
3+const VERSION = "0.1.0"