diff options
author | Mike Crute <mike@crute.us> | 2023-07-29 12:15:13 -0700 |
---|---|---|
committer | Mike Crute <mike@crute.us> | 2023-07-29 12:15:13 -0700 |
commit | 4e995f9e6c3adc43a361b6fa9b976d25378f1594 (patch) | |
tree | 862642149583fa4ad662edfe0b31a7d65b8e302e /cmd | |
parent | fea07831eadd35532055ec16fc43b0cde56a54b1 (diff) | |
download | websocket_proxy-4e995f9e6c3adc43a361b6fa9b976d25378f1594.tar.bz2 websocket_proxy-4e995f9e6c3adc43a361b6fa9b976d25378f1594.tar.xz websocket_proxy-4e995f9e6c3adc43a361b6fa9b976d25378f1594.zip |
Initial import of rewrite
Diffstat (limited to 'cmd')
-rw-r--r-- | cmd/client/client.go | 226 | ||||
-rw-r--r-- | cmd/client/oauth2.go | 158 | ||||
-rw-r--r-- | cmd/register/register.go | 71 | ||||
-rw-r--r-- | cmd/web/server.go | 257 |
4 files changed, 712 insertions, 0 deletions
diff --git a/cmd/client/client.go b/cmd/client/client.go new file mode 100644 index 0000000..62f1f48 --- /dev/null +++ b/cmd/client/client.go | |||
@@ -0,0 +1,226 @@ | |||
1 | package client | ||
2 | |||
3 | import ( | ||
4 | "bytes" | ||
5 | "context" | ||
6 | "crypto/ed25519" | ||
7 | "crypto/rand" | ||
8 | "fmt" | ||
9 | "io" | ||
10 | "log" | ||
11 | "net" | ||
12 | "net/http" | ||
13 | "os" | ||
14 | |||
15 | "code.crute.us/mcrute/ssh-proxy/app" | ||
16 | "code.crute.us/mcrute/ssh-proxy/proxy" | ||
17 | "golang.org/x/crypto/ssh" | ||
18 | "golang.org/x/crypto/ssh/agent" | ||
19 | |||
20 | "code.crute.us/mcrute/golib/cli" | ||
21 | "github.com/gorilla/websocket" | ||
22 | "github.com/mdp/qrterminal" | ||
23 | "github.com/spf13/cobra" | ||
24 | ) | ||
25 | |||
26 | // This should be compiled into the binary | ||
27 | var clientId string | ||
28 | |||
29 | func Register(root *cobra.Command) { | ||
30 | clientCmd := &cobra.Command{ | ||
31 | Use: "client proxy-host ssh-to-host ssh-port username", | ||
32 | Short: "Run websocket client", | ||
33 | Args: cobra.ExactArgs(4), | ||
34 | Run: func(c *cobra.Command, args []string) { | ||
35 | cfg := app.Config{} | ||
36 | cli.MustGetConfig(c, &cfg) | ||
37 | clientMain(cfg, args[0], args[1], args[2], args[3]) | ||
38 | }, | ||
39 | } | ||
40 | cli.AddFlags(clientCmd, &app.Config{}, app.DefaultConfig, "client") | ||
41 | root.AddCommand(clientCmd) | ||
42 | } | ||
43 | |||
44 | func generateCertificateRequest(username, host string) (ed25519.PrivateKey, []byte, error) { | ||
45 | pub, priv, err := ed25519.GenerateKey(rand.Reader) | ||
46 | if err != nil { | ||
47 | return nil, nil, err | ||
48 | } | ||
49 | |||
50 | pubKey, err := ssh.NewPublicKey(pub) | ||
51 | if err != nil { | ||
52 | return nil, nil, err | ||
53 | } | ||
54 | |||
55 | cert := &ssh.Certificate{ | ||
56 | Key: pubKey, | ||
57 | CertType: ssh.UserCert, | ||
58 | ValidPrincipals: []string{username}, | ||
59 | Permissions: ssh.Permissions{ | ||
60 | Extensions: map[string]string{ | ||
61 | // Used for CA policy checks, removed by the CA server | ||
62 | // Server supports a comma separated list without spaces | ||
63 | "allowed-hosts": host, | ||
64 | }, | ||
65 | }, | ||
66 | } | ||
67 | |||
68 | signer, err := ssh.NewSignerFromKey(priv) | ||
69 | if err != nil { | ||
70 | return nil, nil, err | ||
71 | } | ||
72 | |||
73 | // Signatures are required to un/marshal to ASCII. The server will | ||
74 | // discard this anyhow and replace it with its own signature. | ||
75 | if err := cert.SignCert(rand.Reader, signer); err != nil { | ||
76 | return nil, nil, err | ||
77 | } | ||
78 | |||
79 | return priv, ssh.MarshalAuthorizedKey(cert), nil | ||
80 | } | ||
81 | |||
82 | func getCertificateFromCA(ctx context.Context, oauthToken string, certRequest []byte, host string) (*ssh.Certificate, error) { | ||
83 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("https://%s/ca/issue", host), bytes.NewReader(certRequest)) | ||
84 | if err != nil { | ||
85 | return nil, err | ||
86 | } | ||
87 | |||
88 | req.Header.Add("Content-Type", "application/x-ssh-certificate") | ||
89 | req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", oauthToken)) | ||
90 | |||
91 | resp, err := http.DefaultClient.Do(req) | ||
92 | if err != nil { | ||
93 | return nil, err | ||
94 | } | ||
95 | |||
96 | res, err := io.ReadAll(resp.Body) | ||
97 | if err != nil { | ||
98 | return nil, err | ||
99 | } | ||
100 | defer resp.Body.Close() | ||
101 | |||
102 | if resp.StatusCode != http.StatusOK { | ||
103 | return nil, fmt.Errorf("CA returned error: %s", res) | ||
104 | } | ||
105 | |||
106 | pubkey, _, _, _, err := ssh.ParseAuthorizedKey(res) | ||
107 | if err != nil { | ||
108 | return nil, err | ||
109 | } | ||
110 | |||
111 | cert, ok := pubkey.(*ssh.Certificate) | ||
112 | if !ok { | ||
113 | return nil, fmt.Errorf("Parsed certificate is of incorrect type") | ||
114 | } | ||
115 | |||
116 | return cert, nil | ||
117 | } | ||
118 | |||
119 | func addCertificateToAgent(private any, cert *ssh.Certificate) error { | ||
120 | socket := os.Getenv("SSH_AUTH_SOCK") | ||
121 | conn, err := net.Dial("unix", socket) | ||
122 | if err != nil { | ||
123 | return err | ||
124 | } | ||
125 | |||
126 | agentConn := agent.NewClient(conn) | ||
127 | |||
128 | return agentConn.Add(agent.AddedKey{ | ||
129 | PrivateKey: private, | ||
130 | Certificate: cert, | ||
131 | LifetimeSecs: 10, | ||
132 | }) | ||
133 | } | ||
134 | |||
135 | func dialProxyHost(ctx context.Context, oauthToken, proxyHost, host, port string) (io.ReadWriteCloser, error) { | ||
136 | addr := fmt.Sprintf("wss://%s/proxy-to/%s/%s", proxyHost, host, port) | ||
137 | |||
138 | hdr := http.Header{} | ||
139 | hdr.Add("Authorization", fmt.Sprintf("Bearer %s", oauthToken)) | ||
140 | |||
141 | conn, _, err := websocket.DefaultDialer.DialContext(ctx, addr, hdr) | ||
142 | if err != nil { | ||
143 | return nil, err | ||
144 | } | ||
145 | |||
146 | return &proxy.WebsocketReadWriter{W: conn}, nil | ||
147 | } | ||
148 | |||
149 | func fetchOauthToken(ctx context.Context, clientId, proxyHost string) (string, error) { | ||
150 | client := &Oauth2PKCEDeviceClient{ | ||
151 | Host: proxyHost, | ||
152 | ClientId: clientId, | ||
153 | Scope: "ssh:proxy ca:issue", | ||
154 | } | ||
155 | |||
156 | authResponse, err := client.Authorize(ctx) | ||
157 | if err != nil { | ||
158 | return "", err | ||
159 | } | ||
160 | |||
161 | fmt.Fprintf(os.Stderr, | ||
162 | "To authenticate, please visit: \n\n\t%s \n\nEnter code: %s\n\n", | ||
163 | authResponse.VerificationUri, authResponse.UserCode) | ||
164 | |||
165 | if authResponse.VerificationUriComplete != "" { | ||
166 | qrterminal.GenerateWithConfig(authResponse.VerificationUriComplete, qrterminal.Config{ | ||
167 | Level: qrterminal.M, | ||
168 | Writer: os.Stderr, | ||
169 | BlackChar: "\033[7m \033[0m", // White | ||
170 | WhiteChar: "\033[0m \033[0m", // Black | ||
171 | QuietZone: 1, | ||
172 | }) | ||
173 | fmt.Fprintf(os.Stderr, "\n") | ||
174 | } | ||
175 | |||
176 | tokenResponse, err := client.AwaitToken(ctx, authResponse.DeviceCode) | ||
177 | if err != nil { | ||
178 | return "", err | ||
179 | } | ||
180 | |||
181 | return tokenResponse.AccessToken, nil | ||
182 | } | ||
183 | |||
184 | func clientMain(cfg app.Config, proxyHost, host, port, username string) { | ||
185 | log.SetOutput(os.Stderr) | ||
186 | |||
187 | ctx, cancel := context.WithCancel(context.Background()) | ||
188 | defer cancel() | ||
189 | |||
190 | oauthToken, err := fetchOauthToken(ctx, clientId, proxyHost) | ||
191 | if err != nil { | ||
192 | log.Fatalf("Error fetching oauth token: %s", err) | ||
193 | } | ||
194 | |||
195 | privateKey, certRequest, err := generateCertificateRequest(username, host) | ||
196 | if err != nil { | ||
197 | log.Fatalf("Error generating certificate request: %s", err) | ||
198 | } | ||
199 | |||
200 | certificate, err := getCertificateFromCA(ctx, oauthToken, certRequest, proxyHost) | ||
201 | if err != nil { | ||
202 | log.Fatalf("Error fetching certificate: %s", err) | ||
203 | } | ||
204 | |||
205 | if err := addCertificateToAgent(privateKey, certificate); err != nil { | ||
206 | log.Fatalf("Error adding certificate to agent: %s", err) | ||
207 | } | ||
208 | |||
209 | ws, err := dialProxyHost(ctx, oauthToken, proxyHost, host, port) | ||
210 | if err != nil { | ||
211 | log.Fatalf("Error dialing proxy host: %s", err) | ||
212 | } | ||
213 | defer ws.Close() | ||
214 | |||
215 | errc := make(chan error) | ||
216 | |||
217 | go proxy.CopyWithErrors(os.Stdout, ws, errc) | ||
218 | go proxy.CopyWithErrors(ws, os.Stdin, errc) | ||
219 | |||
220 | err = <-errc | ||
221 | if err != nil { | ||
222 | log.Printf("Closing client connection: %s", <-errc) | ||
223 | } else { | ||
224 | log.Printf("Closing client connection") | ||
225 | } | ||
226 | } | ||
diff --git a/cmd/client/oauth2.go b/cmd/client/oauth2.go new file mode 100644 index 0000000..6667c5a --- /dev/null +++ b/cmd/client/oauth2.go | |||
@@ -0,0 +1,158 @@ | |||
1 | package client | ||
2 | |||
3 | import ( | ||
4 | "context" | ||
5 | "encoding/json" | ||
6 | "fmt" | ||
7 | "net/http" | ||
8 | "strings" | ||
9 | "time" | ||
10 | |||
11 | "code.crute.us/mcrute/ssh-proxy/app/models" | ||
12 | |||
13 | "github.com/google/go-querystring/query" | ||
14 | ) | ||
15 | |||
16 | // Oauth2PKCEDeviceClient is not safe for concurrent use and should be | ||
17 | // created anew for each request. | ||
18 | type Oauth2PKCEDeviceClient struct { | ||
19 | Host string | ||
20 | ClientId string | ||
21 | Scope string | ||
22 | pkce *models.PKCEChallenge | ||
23 | interval time.Duration | ||
24 | } | ||
25 | |||
26 | func (c *Oauth2PKCEDeviceClient) Authorize(ctx context.Context) (*models.DeviceAuthorizationResponse, error) { | ||
27 | challenge, err := models.NewPKCEChallenge() | ||
28 | if err != nil { | ||
29 | return nil, err | ||
30 | } | ||
31 | c.pkce = challenge | ||
32 | |||
33 | values, err := query.Values(models.AuthorizationRequest{ | ||
34 | Challenge: c.pkce.Challenge(), | ||
35 | ChallengeMethod: models.ChallengeS256, | ||
36 | ClientId: c.ClientId, | ||
37 | Scope: c.Scope, | ||
38 | }) | ||
39 | if err != nil { | ||
40 | return nil, err | ||
41 | } | ||
42 | |||
43 | url := fmt.Sprintf("https://%s/auth/device", c.Host) | ||
44 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(values.Encode())) | ||
45 | if err != nil { | ||
46 | return nil, err | ||
47 | } | ||
48 | req.Header.Add("Content-Type", "application/x-www-form-urlencoded") | ||
49 | |||
50 | res, err := http.DefaultClient.Do(req) | ||
51 | if err != nil { | ||
52 | return nil, err | ||
53 | } | ||
54 | defer res.Body.Close() | ||
55 | |||
56 | if res.StatusCode != 200 { | ||
57 | var resError models.Oauth2Error | ||
58 | if err := json.NewDecoder(res.Body).Decode(&resError); err != nil { | ||
59 | return nil, err | ||
60 | } | ||
61 | return nil, resError | ||
62 | } | ||
63 | |||
64 | var resp models.DeviceAuthorizationResponse | ||
65 | if err := json.NewDecoder(res.Body).Decode(&resp); err != nil { | ||
66 | return nil, err | ||
67 | } | ||
68 | |||
69 | c.interval = time.Duration(resp.Interval) * time.Second | ||
70 | if c.interval == 0 { | ||
71 | c.interval = 5 * time.Second | ||
72 | } | ||
73 | |||
74 | return &resp, nil | ||
75 | } | ||
76 | |||
77 | func (c *Oauth2PKCEDeviceClient) fetchToken(ctx context.Context, deviceCode string) (*models.AccessTokenResponse, error) { | ||
78 | values, err := query.Values(models.DeviceAccessTokenRequest{ | ||
79 | GrantType: models.DEVICE_CODE_GRANT_TYPE, | ||
80 | DeviceCode: deviceCode, | ||
81 | ClientId: c.ClientId, | ||
82 | CodeVerifier: c.pkce.Verifier, | ||
83 | }) | ||
84 | if err != nil { | ||
85 | return nil, err | ||
86 | } | ||
87 | |||
88 | url := fmt.Sprintf("https://%s/auth/token", c.Host) | ||
89 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(values.Encode())) | ||
90 | if err != nil { | ||
91 | return nil, err | ||
92 | } | ||
93 | req.Header.Add("Content-Type", "application/x-www-form-urlencoded") | ||
94 | |||
95 | res, err := http.DefaultClient.Do(req) | ||
96 | if err != nil { | ||
97 | return nil, err | ||
98 | } | ||
99 | defer res.Body.Close() | ||
100 | |||
101 | if res.StatusCode != 200 { | ||
102 | var resError models.Oauth2Error | ||
103 | if err := json.NewDecoder(res.Body).Decode(&resError); err != nil { | ||
104 | return nil, err | ||
105 | } | ||
106 | |||
107 | if resError.Type == models.ErrSlowDown { | ||
108 | c.interval += 5 * time.Second | ||
109 | } | ||
110 | |||
111 | return nil, resError | ||
112 | } | ||
113 | |||
114 | var resp models.AccessTokenResponse | ||
115 | if err := json.NewDecoder(res.Body).Decode(&resp); err != nil { | ||
116 | return nil, err | ||
117 | } | ||
118 | |||
119 | return &resp, nil | ||
120 | } | ||
121 | |||
122 | func (c *Oauth2PKCEDeviceClient) AwaitToken(ctx context.Context, deviceCode string) (*models.AccessTokenResponse, error) { | ||
123 | t := time.NewTicker(c.interval) | ||
124 | defer t.Stop() | ||
125 | |||
126 | res, err := c.fetchToken(ctx, deviceCode) | ||
127 | if err == nil { | ||
128 | return res, nil | ||
129 | } else if e, ok := err.(models.Oauth2Error); ok { | ||
130 | if e.Type == models.ErrSlowDown { | ||
131 | t.Reset(c.interval) | ||
132 | } else if e.Type != models.ErrAuthorizationPending { | ||
133 | return nil, err | ||
134 | } | ||
135 | } else { | ||
136 | return nil, err | ||
137 | } | ||
138 | |||
139 | for { | ||
140 | select { | ||
141 | case <-t.C: | ||
142 | res, err := c.fetchToken(ctx, deviceCode) | ||
143 | if err == nil { | ||
144 | return res, nil | ||
145 | } else if e, ok := err.(models.Oauth2Error); ok { | ||
146 | if e.Type == models.ErrSlowDown { | ||
147 | t.Reset(c.interval) | ||
148 | } else if e.Type != models.ErrAuthorizationPending { | ||
149 | return nil, err | ||
150 | } | ||
151 | } else { | ||
152 | return nil, err | ||
153 | } | ||
154 | case <-ctx.Done(): | ||
155 | return nil, fmt.Errorf("Context has expired") | ||
156 | } | ||
157 | } | ||
158 | } | ||
diff --git a/cmd/register/register.go b/cmd/register/register.go new file mode 100644 index 0000000..fdd083c --- /dev/null +++ b/cmd/register/register.go | |||
@@ -0,0 +1,71 @@ | |||
1 | package register | ||
2 | |||
3 | import ( | ||
4 | "context" | ||
5 | "log" | ||
6 | "time" | ||
7 | |||
8 | "code.crute.us/mcrute/golib/cli" | ||
9 | "code.crute.us/mcrute/golib/db/mongodb/v2" | ||
10 | glecho "code.crute.us/mcrute/golib/echo" | ||
11 | "code.crute.us/mcrute/ssh-proxy/app" | ||
12 | "code.crute.us/mcrute/ssh-proxy/app/models" | ||
13 | "code.crute.us/mcrute/ssh-proxy/db" | ||
14 | "github.com/spf13/cobra" | ||
15 | ) | ||
16 | |||
17 | func Register(root *cobra.Command) { | ||
18 | registerCmd := &cobra.Command{ | ||
19 | Use: "register username", | ||
20 | Short: "Create registration invite for user", | ||
21 | Args: cobra.ExactArgs(1), | ||
22 | Run: func(c *cobra.Command, args []string) { | ||
23 | cfg := app.Config{} | ||
24 | cli.MustGetConfig(c, &cfg) | ||
25 | registerMain(cfg, args[0]) | ||
26 | }, | ||
27 | } | ||
28 | cli.AddFlags(registerCmd, &app.Config{}, app.DefaultConfig, "register") | ||
29 | root.AddCommand(registerCmd) | ||
30 | } | ||
31 | |||
32 | func registerMain(cfg app.Config, username string) { | ||
33 | ctx, cancel := context.WithCancel(context.Background()) | ||
34 | defer cancel() | ||
35 | |||
36 | vc, err := glecho.MakeVaultSecretsClient(ctx) | ||
37 | if err != nil { | ||
38 | log.Fatalf("Error making vault client %s", err) | ||
39 | } | ||
40 | |||
41 | mongo, err := mongodb.Connect(ctx, cfg.MongoDbUri, vc) | ||
42 | if err != nil { | ||
43 | log.Fatalf("Error connecting to mongodb: %s", err) | ||
44 | } | ||
45 | |||
46 | userStore := &db.MongoDbBasicStore[*models.User]{ | ||
47 | Db: mongo, | ||
48 | CollectionName: "users", | ||
49 | } | ||
50 | |||
51 | authSessionStore := &models.AuthSessionStoreMongodb{ | ||
52 | MongoDbBasicStore: &db.MongoDbBasicStore[*models.AuthSession]{ | ||
53 | Db: mongo, | ||
54 | CollectionName: "oauth_sessions", | ||
55 | }, | ||
56 | } | ||
57 | |||
58 | if _, err := userStore.Get(ctx, username); err != nil { | ||
59 | log.Fatalf("User %s does not exist", username) | ||
60 | } | ||
61 | |||
62 | authSession := models.NewAuthSession("invite-only", time.Now().Add(cfg.InviteTimeout)) | ||
63 | authSession.IsRegistration = true | ||
64 | authSession.UserId = username | ||
65 | |||
66 | if err := authSessionStore.Upsert(ctx, authSession); err != nil { | ||
67 | log.Fatalf("Error inserting registration: %s", err) | ||
68 | } | ||
69 | |||
70 | log.Printf("Invitation created, user code is: %s", authSession.UserCode) | ||
71 | } | ||
diff --git a/cmd/web/server.go b/cmd/web/server.go new file mode 100644 index 0000000..6eb585a --- /dev/null +++ b/cmd/web/server.go | |||
@@ -0,0 +1,257 @@ | |||
1 | package web | ||
2 | |||
3 | import ( | ||
4 | "context" | ||
5 | "fmt" | ||
6 | "io/fs" | ||
7 | "log" | ||
8 | "os" | ||
9 | "strings" | ||
10 | "text/template" | ||
11 | "time" | ||
12 | |||
13 | "code.crute.us/mcrute/ssh-proxy/app" | ||
14 | "code.crute.us/mcrute/ssh-proxy/app/controllers" | ||
15 | "code.crute.us/mcrute/ssh-proxy/app/middleware" | ||
16 | "code.crute.us/mcrute/ssh-proxy/app/models" | ||
17 | "code.crute.us/mcrute/ssh-proxy/db" | ||
18 | |||
19 | "code.crute.us/mcrute/golib/cli" | ||
20 | "code.crute.us/mcrute/golib/clients/autocert/v2" | ||
21 | "code.crute.us/mcrute/golib/clients/netbox/v3" | ||
22 | "code.crute.us/mcrute/golib/db/mongodb/v2" | ||
23 | glecho "code.crute.us/mcrute/golib/echo" | ||
24 | glcontroller "code.crute.us/mcrute/golib/echo/controller" | ||
25 | glmiddleware "code.crute.us/mcrute/golib/echo/middleware" | ||
26 | "code.crute.us/mcrute/golib/echo/session" | ||
27 | "code.crute.us/mcrute/golib/secrets" | ||
28 | |||
29 | "github.com/go-webauthn/webauthn/webauthn" | ||
30 | "github.com/gorilla/websocket" | ||
31 | "github.com/labstack/echo/v4" | ||
32 | "github.com/spf13/cobra" | ||
33 | ) | ||
34 | |||
35 | func Register(root *cobra.Command, embeddedTemplates fs.FS, appVersion string) { | ||
36 | webCmd := &cobra.Command{ | ||
37 | Use: "web [options]", | ||
38 | Short: "Run web server", | ||
39 | Run: func(c *cobra.Command, args []string) { | ||
40 | cfg := app.Config{} | ||
41 | cli.MustGetConfig(c, &cfg) | ||
42 | webMain(cfg, embeddedTemplates, appVersion) | ||
43 | }, | ||
44 | } | ||
45 | cli.AddFlags(webCmd, &app.Config{}, app.DefaultConfig, "web") | ||
46 | root.AddCommand(webCmd) | ||
47 | } | ||
48 | |||
49 | func PopulateTemplateContext(c echo.Context) (interface{}, error) { | ||
50 | // May not be set if we're being called from something other than | ||
51 | // the generic template controller, which can happen in the order | ||
52 | // redirect controller. | ||
53 | cp, _ := c.Get("CanonicalPath").(string) | ||
54 | |||
55 | return &app.PageContext{ | ||
56 | PageName: strings.SplitN(cp, ".", 2)[0], | ||
57 | Year: time.Now().Year(), | ||
58 | RenderTime: time.Now().Format(time.RFC1123), | ||
59 | Flags: glcontroller.NewFeatureFlags(), | ||
60 | CSRFToken: glmiddleware.GetCSRFToken(c), | ||
61 | Context: glcontroller.NewPageContext(), | ||
62 | }, nil | ||
63 | } | ||
64 | |||
65 | func webMain(cfg app.Config, embeddedTemplates fs.FS, appVersion string) { | ||
66 | ctx, cancel := context.WithCancel(context.Background()) | ||
67 | defer cancel() | ||
68 | |||
69 | gt := &glcontroller.GenericTemplateHandler{Render: PopulateTemplateContext} | ||
70 | |||
71 | s, err := glecho.NewEchoWrapper(ctx, cfg.Debug) | ||
72 | if err != nil { | ||
73 | log.Fatalf("Error building echo: %s", err) | ||
74 | } | ||
75 | |||
76 | vc, err := glecho.MakeVaultSecretsClient(ctx) | ||
77 | if err != nil { | ||
78 | log.Fatalf("Error making vault client %s", err) | ||
79 | } | ||
80 | |||
81 | if err = s.Configure(glecho.EchoConfig{ | ||
82 | ApplicationName: "app-server", | ||
83 | ApplicationVersion: appVersion, | ||
84 | BindAddresses: cfg.Bind, | ||
85 | DiskTemplates: os.DirFS("templates/"), | ||
86 | EmbeddedTemplates: embeddedTemplates, | ||
87 | RedirectToWWW: false, | ||
88 | TrustedProxyIPRanges: cfg.TrustedIPRanges, | ||
89 | ContentSecurityPolicy: &glmiddleware.ContentSecurityPolicyConfig{ | ||
90 | DefaultSrc: []glmiddleware.CSPDirective{ | ||
91 | glmiddleware.CSPSelf, | ||
92 | glmiddleware.CSPUnsafeInline, | ||
93 | }, | ||
94 | }, | ||
95 | TemplateFunctions: template.FuncMap{ | ||
96 | "cacheBustUrl": gt.TmplMakeCacheBustUrl, | ||
97 | }, | ||
98 | Autocert: autocert.MustNewAutocertWrapper(ctx, autocert.AutocertConfig{ | ||
99 | ApiKey: secrets.MustGetApiKey(vc, ctx, cfg.DNSApiKeyVaultPath).Key, | ||
100 | Hosts: cfg.Hostnames, | ||
101 | Email: cfg.AutocertEmail, | ||
102 | CertHost: cfg.AutocertHost, | ||
103 | }), | ||
104 | NetboxClient: &netbox.BasicNetboxClient{ | ||
105 | Endpoint: cfg.NetboxHost, | ||
106 | ApiKey: secrets.MustGetApiKey(vc, ctx, cfg.NetboxApiKeyVaultPath).Key, | ||
107 | }, | ||
108 | }); err != nil { | ||
109 | log.Fatalf("Error configuring echo: %s", err) | ||
110 | } | ||
111 | |||
112 | glecho.AttachSecretsClient(vc, cancel, s.Runner(), s.Logger) | ||
113 | |||
114 | mongo, err := mongodb.Connect(ctx, cfg.MongoDbUri, vc) | ||
115 | if err != nil { | ||
116 | log.Fatalf("Error connecting to mongodb: %s", err) | ||
117 | } | ||
118 | |||
119 | cookieKey := secrets.MustGetRSAKey(vc, ctx, cfg.CookieKeyPath) | ||
120 | pk, err := cookieKey.RSAPrivateKey() | ||
121 | if err != nil { | ||
122 | log.Fatalf("Error fetching cookie key from vault: %s", err) | ||
123 | } | ||
124 | |||
125 | ss, err := session.NewCookieStore[*app.Session](pk, app.NewSession) | ||
126 | if err != nil { | ||
127 | log.Fatalf("Error creating session store: %s", err) | ||
128 | } | ||
129 | |||
130 | userStore := &db.MongoDbBasicStore[*models.User]{ | ||
131 | Db: mongo, | ||
132 | CollectionName: "users", | ||
133 | } | ||
134 | |||
135 | oauthClientStore := &db.MongoDbBasicStore[*models.OauthClient]{ | ||
136 | Db: mongo, | ||
137 | CollectionName: "oauth_clients", | ||
138 | } | ||
139 | |||
140 | authSessionStore := &models.AuthSessionStoreMongodb{ | ||
141 | MongoDbBasicStore: &db.MongoDbBasicStore[*models.AuthSession]{ | ||
142 | Db: mongo, | ||
143 | CollectionName: "oauth_sessions", | ||
144 | }, | ||
145 | } | ||
146 | |||
147 | wauthn, err := webauthn.New(&webauthn.Config{ | ||
148 | RPDisplayName: cfg.OauthRPName, | ||
149 | RPID: cfg.Hostnames[0], | ||
150 | RPOrigins: []string{ | ||
151 | fmt.Sprintf("https://%s:8070", cfg.Hostnames[0]), // TODO: Expose port in echo server for use here | ||
152 | }, | ||
153 | }) | ||
154 | if err != nil { | ||
155 | log.Fatalf("Error constructing webauthn: %s", err) | ||
156 | } | ||
157 | |||
158 | lc := &controllers.LoginController[*app.Session]{ | ||
159 | Logger: s.Logger, | ||
160 | Sessions: ss, | ||
161 | Users: userStore, | ||
162 | AuthSessions: authSessionStore, | ||
163 | Webauthn: wauthn, | ||
164 | SessionExpiration: cfg.OauthSessionTimeout, | ||
165 | } | ||
166 | |||
167 | rc := &controllers.RegisterController[*app.Session]{ | ||
168 | Logger: s.Logger, | ||
169 | Sessions: ss, | ||
170 | Users: userStore, | ||
171 | AuthSessions: authSessionStore, | ||
172 | Webauthn: wauthn, | ||
173 | } | ||
174 | |||
175 | o2dc := &controllers.OAuth2DeviceController[*app.Session]{ | ||
176 | Logger: s.Logger, | ||
177 | AuthSessions: authSessionStore, | ||
178 | OauthClients: oauthClientStore, | ||
179 | Hostname: fmt.Sprintf("https://%s:8070", cfg.Hostnames[0]), // TODO | ||
180 | PollSeconds: cfg.OauthDevicePollSecs, | ||
181 | SessionExpiration: cfg.OauthSessionTimeout, | ||
182 | } | ||
183 | |||
184 | ph := &controllers.ProxyHandler{ | ||
185 | Logger: s.Logger, | ||
186 | Users: userStore, | ||
187 | Upgrader: websocket.Upgrader{ | ||
188 | ReadBufferSize: 1024, | ||
189 | WriteBufferSize: 1024, | ||
190 | }, | ||
191 | } | ||
192 | |||
193 | caAuthMw := &middleware.TokenAuthMiddleware{ | ||
194 | Logger: s.Logger, | ||
195 | RequiredScope: "ca:issue", | ||
196 | AuthSessions: authSessionStore, | ||
197 | } | ||
198 | |||
199 | proxyAuthMw := &middleware.TokenAuthMiddleware{ | ||
200 | Logger: s.Logger, | ||
201 | RequiredScope: "ssh:proxy", | ||
202 | AuthSessions: authSessionStore, | ||
203 | } | ||
204 | |||
205 | var caSecret controllers.CASecret | ||
206 | if _, err := vc.Secret(ctx, cfg.SSHCAKeyPath, &caSecret); err != nil { | ||
207 | log.Fatalf("Error fetching SSH CA secret from Vault: %s", err) | ||
208 | } | ||
209 | |||
210 | ca, err := controllers.NewCAHandler(controllers.CAHandlerConfig{ | ||
211 | Logger: s.Logger, | ||
212 | Users: userStore, | ||
213 | Expiration: cfg.SSHCertificateExpiration, | ||
214 | Secret: caSecret, | ||
215 | }) | ||
216 | if err != nil { | ||
217 | log.Fatalf("Error building CA controller: %s", err) | ||
218 | } | ||
219 | |||
220 | s.Use(session.Middleware(ss)) | ||
221 | |||
222 | csm := glmiddleware.CSRFProtect(ss) | ||
223 | |||
224 | s.GET("/login", gt.Handle, csm) | ||
225 | s.GET("/register", gt.Handle, csm) | ||
226 | |||
227 | ag := s.Group("/auth") | ||
228 | { | ||
229 | ag.POST("/device", o2dc.HandleStart) | ||
230 | ag.POST("/token", o2dc.HandleToken) | ||
231 | |||
232 | lg := ag.Group("/login") | ||
233 | lg.Use(csm) | ||
234 | { | ||
235 | lg.GET("/:username", lc.HandleStart) | ||
236 | lg.POST("/:username", lc.HandleFinish) | ||
237 | } | ||
238 | |||
239 | rg := ag.Group("/register") | ||
240 | rg.Use(csm) | ||
241 | { | ||
242 | rg.GET("/:username", rc.HandleStart) | ||
243 | rg.POST("/:username", rc.HandleFinish) | ||
244 | } | ||
245 | } | ||
246 | |||
247 | s.POST("/ca/issue", ca.HandleIssue, caAuthMw.Middleware) | ||
248 | |||
249 | pg := s.Group("/proxy-to") | ||
250 | pg.Use(proxyAuthMw.Middleware) | ||
251 | { | ||
252 | pg.GET("/:host", ph.Handle) | ||
253 | pg.GET("/:host/:port", ph.Handle) | ||
254 | } | ||
255 | |||
256 | s.RunForever(!cfg.DisableBackgroundJobs) | ||
257 | } | ||