diff --git a/agent/agentssh/agentssh.go b/agent/agentssh/agentssh.go index 293dd4db169ac..b802ede9c93e7 100644 --- a/agent/agentssh/agentssh.go +++ b/agent/agentssh/agentssh.go @@ -124,6 +124,7 @@ type Server struct { listeners map[net.Listener]struct{} conns map[net.Conn]struct{} sessions map[ssh.Session]struct{} + processes map[*os.Process]struct{} closing chan struct{} // Wait for goroutines to exit, waited without // a lock on mu but protected by closing. @@ -182,6 +183,7 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom fs: fs, conns: make(map[net.Conn]struct{}), sessions: make(map[ssh.Session]struct{}), + processes: make(map[*os.Process]struct{}), logger: logger, config: config, @@ -586,7 +588,10 @@ func (s *Server) startNonPTYSession(logger slog.Logger, session ssh.Session, mag // otherwise context cancellation will not propagate properly // and SSH server close may be delayed. cmd.SysProcAttr = cmdSysProcAttr() - cmd.Cancel = cmdCancel(session.Context(), logger, cmd) + + // to match OpenSSH, we don't actually tear a non-TTY command down, even if the session ends. + // c.f. https://github.com/coder/coder/issues/18519#issuecomment-3019118271 + cmd.Cancel = nil cmd.Stdout = session cmd.Stderr = session.Stderr() @@ -609,6 +614,16 @@ func (s *Server) startNonPTYSession(logger slog.Logger, session ssh.Session, mag s.metrics.sessionErrors.WithLabelValues(magicTypeLabel, "no", "start_command").Add(1) return xerrors.Errorf("start: %w", err) } + + // Since we don't cancel the process when the session stops, we still need to tear it down if we are closing. So + // track it here. + if !s.trackProcess(cmd.Process, true) { + // must be closing + err = cmdCancel(logger, cmd.Process) + return xerrors.Errorf("failed to track process: %w", err) + } + defer s.trackProcess(cmd.Process, false) + sigs := make(chan ssh.Signal, 1) session.Signals(sigs) defer func() { @@ -1052,6 +1067,27 @@ func (s *Server) trackSession(ss ssh.Session, add bool) (ok bool) { return true } +// trackCommand registers the process with the server. If the server is +// closing, the process is not registered and should be closed. +// +//nolint:revive +func (s *Server) trackProcess(p *os.Process, add bool) (ok bool) { + s.mu.Lock() + defer s.mu.Unlock() + if add { + if s.closing != nil { + // Server closed. + return false + } + s.wg.Add(1) + s.processes[p] = struct{}{} + return true + } + s.wg.Done() + delete(s.processes, p) + return true +} + // Close the server and all active connections. Server can be re-used // after Close is done. func (s *Server) Close() error { @@ -1091,6 +1127,10 @@ func (s *Server) Close() error { _ = c.Close() } + for p := range s.processes { + _ = cmdCancel(s.logger, p) + } + s.logger.Debug(ctx, "closing SSH server") err := s.srv.Close() diff --git a/agent/agentssh/exec_other.go b/agent/agentssh/exec_other.go index 54dfd50899412..aef496a1ef775 100644 --- a/agent/agentssh/exec_other.go +++ b/agent/agentssh/exec_other.go @@ -4,7 +4,7 @@ package agentssh import ( "context" - "os/exec" + "os" "syscall" "cdr.dev/slog" @@ -16,9 +16,7 @@ func cmdSysProcAttr() *syscall.SysProcAttr { } } -func cmdCancel(ctx context.Context, logger slog.Logger, cmd *exec.Cmd) func() error { - return func() error { - logger.Debug(ctx, "cmdCancel: sending SIGHUP to process and children", slog.F("pid", cmd.Process.Pid)) - return syscall.Kill(-cmd.Process.Pid, syscall.SIGHUP) - } +func cmdCancel(logger slog.Logger, p *os.Process) error { + logger.Debug(context.Background(), "cmdCancel: sending SIGHUP to process and children", slog.F("pid", p.Pid)) + return syscall.Kill(-p.Pid, syscall.SIGHUP) } diff --git a/agent/agentssh/exec_windows.go b/agent/agentssh/exec_windows.go index 39f0f97198479..0dafa67958a67 100644 --- a/agent/agentssh/exec_windows.go +++ b/agent/agentssh/exec_windows.go @@ -2,7 +2,7 @@ package agentssh import ( "context" - "os/exec" + "os" "syscall" "cdr.dev/slog" @@ -12,14 +12,12 @@ func cmdSysProcAttr() *syscall.SysProcAttr { return &syscall.SysProcAttr{} } -func cmdCancel(ctx context.Context, logger slog.Logger, cmd *exec.Cmd) func() error { - return func() error { - logger.Debug(ctx, "cmdCancel: killing process", slog.F("pid", cmd.Process.Pid)) - // Windows doesn't support sending signals to process groups, so we - // have to kill the process directly. In the future, we may want to - // implement a more sophisticated solution for process groups on - // Windows, but for now, this is a simple way to ensure that the - // process is terminated when the context is cancelled. - return cmd.Process.Kill() - } +func cmdCancel(logger slog.Logger, p *os.Process) error { + logger.Debug(context.Background(), "cmdCancel: killing process", slog.F("pid", p.Pid)) + // Windows doesn't support sending signals to process groups, so we + // have to kill the process directly. In the future, we may want to + // implement a more sophisticated solution for process groups on + // Windows, but for now, this is a simple way to ensure that the + // process is terminated when the context is cancelled. + return p.Kill() } diff --git a/cli/ssh.go b/cli/ssh.go index 7c5bda073f973..dd0568dc5e14c 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -1569,12 +1569,14 @@ func writeCoderConnectNetInfo(ctx context.Context, networkInfoDir string) error // Converts workspace name input to owner/workspace.agent format // Possible valid input formats: // workspace +// workspace.agent // owner/workspace // owner--workspace // owner/workspace--agent // owner/workspace.agent // owner--workspace--agent // owner--workspace.agent +// agent.workspace.owner - for parity with Coder Connect func normalizeWorkspaceInput(input string) string { // Split on "/", "--", and "." parts := workspaceNameRe.Split(input, -1) @@ -1583,8 +1585,15 @@ func normalizeWorkspaceInput(input string) string { case 1: return input // "workspace" case 2: + if strings.Contains(input, ".") { + return fmt.Sprintf("%s.%s", parts[0], parts[1]) // "workspace.agent" + } return fmt.Sprintf("%s/%s", parts[0], parts[1]) // "owner/workspace" case 3: + // If the only separator is a dot, it's the Coder Connect format + if !strings.Contains(input, "/") && !strings.Contains(input, "--") { + return fmt.Sprintf("%s/%s.%s", parts[2], parts[1], parts[0]) // "owner/workspace.agent" + } return fmt.Sprintf("%s/%s.%s", parts[0], parts[1], parts[2]) // "owner/workspace.agent" default: return input // Fallback diff --git a/cli/ssh_test.go b/cli/ssh_test.go index 5fcb6205d5e45..9f85652029f50 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -107,12 +107,14 @@ func TestSSH(t *testing.T) { cases := []string{ "myworkspace", + "myworkspace.dev", "myuser/myworkspace", "myuser--myworkspace", "myuser/myworkspace--dev", "myuser/myworkspace.dev", "myuser--myworkspace--dev", "myuser--myworkspace.dev", + "dev.myworkspace.myuser", } for _, tc := range cases { diff --git a/coderd/coderdtest/oidctest/idp.go b/coderd/coderdtest/oidctest/idp.go index b82f8a00dedb4..c7f7d35937198 100644 --- a/coderd/coderdtest/oidctest/idp.go +++ b/coderd/coderdtest/oidctest/idp.go @@ -307,7 +307,7 @@ func WithCustomClientAuth(hook func(t testing.TB, req *http.Request) (url.Values // WithLogging is optional, but will log some HTTP calls made to the IDP. func WithLogging(t testing.TB, options *slogtest.Options) func(*FakeIDP) { return func(f *FakeIDP) { - f.logger = slogtest.Make(t, options) + f.logger = slogtest.Make(t, options).Named("fakeidp") } } @@ -794,6 +794,7 @@ func (f *FakeIDP) newToken(t testing.TB, email string, expires time.Time) string func (f *FakeIDP) newRefreshTokens(email string) string { refreshToken := uuid.NewString() f.refreshTokens.Store(refreshToken, email) + f.logger.Info(context.Background(), "new refresh token", slog.F("email", email), slog.F("token", refreshToken)) return refreshToken } @@ -1003,6 +1004,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler { return } + f.logger.Info(r.Context(), "http idp call refresh_token", slog.F("token", refreshToken)) _, ok := f.refreshTokens.Load(refreshToken) if !assert.True(t, ok, "invalid refresh_token") { http.Error(rw, "invalid refresh_token", http.StatusBadRequest) @@ -1026,6 +1028,7 @@ func (f *FakeIDP) httpHandler(t testing.TB) http.Handler { f.refreshTokensUsed.Store(refreshToken, true) // Always invalidate the refresh token after it is used. f.refreshTokens.Delete(refreshToken) + f.logger.Info(r.Context(), "refresh token invalidated", slog.F("token", refreshToken)) case "urn:ietf:params:oauth:grant-type:device_code": // Device flow var resp externalauth.ExchangeDeviceCodeResponse diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index d614b37a3d897..4b92848b773e2 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -232,16 +232,21 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon return optionalWrite(http.StatusUnauthorized, resp) } - var ( - link database.UserLink - now = dbtime.Now() - // Tracks if the API key has properties updated - changed = false - ) + now := dbtime.Now() + if key.ExpiresAt.Before(now) { + return optionalWrite(http.StatusUnauthorized, codersdk.Response{ + Message: SignedOutErrorMessage, + Detail: fmt.Sprintf("API key expired at %q.", key.ExpiresAt.String()), + }) + } + + // We only check OIDC stuff if we have a valid APIKey. An expired key means we don't trust the requestor + // really is the user whose key they have, and so we shouldn't be doing anything on their behalf including possibly + // refreshing the OIDC token. if key.LoginType == database.LoginTypeGithub || key.LoginType == database.LoginTypeOIDC { var err error //nolint:gocritic // System needs to fetch UserLink to check if it's valid. - link, err = cfg.DB.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx), database.GetUserLinkByUserIDLoginTypeParams{ + link, err := cfg.DB.GetUserLinkByUserIDLoginType(dbauthz.AsSystemRestricted(ctx), database.GetUserLinkByUserIDLoginTypeParams{ UserID: key.UserID, LoginType: key.LoginType, }) @@ -258,7 +263,7 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon }) } // Check if the OAuth token is expired - if link.OAuthExpiry.Before(now) && !link.OAuthExpiry.IsZero() && link.OAuthRefreshToken != "" { + if !link.OAuthExpiry.IsZero() && link.OAuthExpiry.Before(now) { if cfg.OAuth2Configs.IsZero() { return write(http.StatusInternalServerError, codersdk.Response{ Message: internalErrorMessage, @@ -267,12 +272,15 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon }) } + var friendlyName string var oauthConfig promoauth.OAuth2Config switch key.LoginType { case database.LoginTypeGithub: oauthConfig = cfg.OAuth2Configs.Github + friendlyName = "GitHub" case database.LoginTypeOIDC: oauthConfig = cfg.OAuth2Configs.OIDC + friendlyName = "OpenID Connect" default: return write(http.StatusInternalServerError, codersdk.Response{ Message: internalErrorMessage, @@ -292,7 +300,13 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon }) } - // If it is, let's refresh it from the provided config + if link.OAuthRefreshToken == "" { + return optionalWrite(http.StatusUnauthorized, codersdk.Response{ + Message: SignedOutErrorMessage, + Detail: fmt.Sprintf("%s session expired at %q. Try signing in again.", friendlyName, link.OAuthExpiry.String()), + }) + } + // We have a refresh token, so let's try it token, err := oauthConfig.TokenSource(r.Context(), &oauth2.Token{ AccessToken: link.OAuthAccessToken, RefreshToken: link.OAuthRefreshToken, @@ -300,28 +314,39 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon }).Token() if err != nil { return write(http.StatusUnauthorized, codersdk.Response{ - Message: "Could not refresh expired Oauth token. Try re-authenticating to resolve this issue.", - Detail: err.Error(), + Message: fmt.Sprintf( + "Could not refresh expired %s token. Try re-authenticating to resolve this issue.", + friendlyName), + Detail: err.Error(), }) } link.OAuthAccessToken = token.AccessToken link.OAuthRefreshToken = token.RefreshToken link.OAuthExpiry = token.Expiry - key.ExpiresAt = token.Expiry - changed = true + //nolint:gocritic // system needs to update user link + link, err = cfg.DB.UpdateUserLink(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLinkParams{ + UserID: link.UserID, + LoginType: link.LoginType, + OAuthAccessToken: link.OAuthAccessToken, + OAuthAccessTokenKeyID: sql.NullString{}, // dbcrypt will update as required + OAuthRefreshToken: link.OAuthRefreshToken, + OAuthRefreshTokenKeyID: sql.NullString{}, // dbcrypt will update as required + OAuthExpiry: link.OAuthExpiry, + // Refresh should keep the same debug context because we use + // the original claims for the group/role sync. + Claims: link.Claims, + }) + if err != nil { + return write(http.StatusInternalServerError, codersdk.Response{ + Message: internalErrorMessage, + Detail: fmt.Sprintf("update user_link: %s.", err.Error()), + }) + } } } - // Checking if the key is expired. - // NOTE: The `RequireAuth` React component depends on this `Detail` to detect when - // the users token has expired. If you change the text here, make sure to update it - // in site/src/components/RequireAuth/RequireAuth.tsx as well. - if key.ExpiresAt.Before(now) { - return optionalWrite(http.StatusUnauthorized, codersdk.Response{ - Message: SignedOutErrorMessage, - Detail: fmt.Sprintf("API key expired at %q.", key.ExpiresAt.String()), - }) - } + // Tracks if the API key has properties updated + changed := false // Only update LastUsed once an hour to prevent database spam. if now.Sub(key.LastUsed) > time.Hour { @@ -363,29 +388,6 @@ func ExtractAPIKey(rw http.ResponseWriter, r *http.Request, cfg ExtractAPIKeyCon Detail: fmt.Sprintf("API key couldn't update: %s.", err.Error()), }) } - // If the API Key is associated with a user_link (e.g. Github/OIDC) - // then we want to update the relevant oauth fields. - if link.UserID != uuid.Nil { - //nolint:gocritic // system needs to update user link - link, err = cfg.DB.UpdateUserLink(dbauthz.AsSystemRestricted(ctx), database.UpdateUserLinkParams{ - UserID: link.UserID, - LoginType: link.LoginType, - OAuthAccessToken: link.OAuthAccessToken, - OAuthAccessTokenKeyID: sql.NullString{}, // dbcrypt will update as required - OAuthRefreshToken: link.OAuthRefreshToken, - OAuthRefreshTokenKeyID: sql.NullString{}, // dbcrypt will update as required - OAuthExpiry: link.OAuthExpiry, - // Refresh should keep the same debug context because we use - // the original claims for the group/role sync. - Claims: link.Claims, - }) - if err != nil { - return write(http.StatusInternalServerError, codersdk.Response{ - Message: internalErrorMessage, - Detail: fmt.Sprintf("update user_link: %s.", err.Error()), - }) - } - } // We only want to update this occasionally to reduce DB write // load. We update alongside the UserLink and APIKey since it's diff --git a/coderd/httpmw/apikey_test.go b/coderd/httpmw/apikey_test.go index bd979e88235ad..6e2e75ace9825 100644 --- a/coderd/httpmw/apikey_test.go +++ b/coderd/httpmw/apikey_test.go @@ -508,6 +508,102 @@ func TestAPIKey(t *testing.T) { require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt) }) + t.Run("APIKeyExpiredOAuthExpired", func(t *testing.T) { + t.Parallel() + var ( + db = dbmem.New() + user = dbgen.User(t, db, database.User{}) + sentAPIKey, token = dbgen.APIKey(t, db, database.APIKey{ + UserID: user.ID, + LastUsed: dbtime.Now().AddDate(0, 0, -1), + ExpiresAt: dbtime.Now().AddDate(0, 0, -1), + LoginType: database.LoginTypeOIDC, + }) + _ = dbgen.UserLink(t, db, database.UserLink{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + OAuthExpiry: dbtime.Now().AddDate(0, 0, -1), + }) + + r = httptest.NewRequest("GET", "/", nil) + rw = httptest.NewRecorder() + ) + r.Header.Set(codersdk.SessionTokenHeader, token) + + // Include a valid oauth token for refreshing. If this token is invalid, + // it is difficult to tell an auth failure from an expired api key, or + // an expired oauth key. + oauthToken := &oauth2.Token{ + AccessToken: "wow", + RefreshToken: "moo", + Expiry: dbtime.Now().AddDate(0, 0, 1), + } + httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ + DB: db, + OAuth2Configs: &httpmw.OAuth2Configs{ + OIDC: &testutil.OAuth2Config{ + Token: oauthToken, + }, + }, + RedirectToLogin: false, + })(successHandler).ServeHTTP(rw, r) + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + + gotAPIKey, err := db.GetAPIKeyByID(r.Context(), sentAPIKey.ID) + require.NoError(t, err) + + require.Equal(t, sentAPIKey.LastUsed, gotAPIKey.LastUsed) + require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt) + }) + + t.Run("APIKeyExpiredOAuthNotExpired", func(t *testing.T) { + t.Parallel() + var ( + db = dbmem.New() + user = dbgen.User(t, db, database.User{}) + sentAPIKey, token = dbgen.APIKey(t, db, database.APIKey{ + UserID: user.ID, + LastUsed: dbtime.Now().AddDate(0, 0, -1), + ExpiresAt: dbtime.Now().AddDate(0, 0, -1), + LoginType: database.LoginTypeOIDC, + }) + _ = dbgen.UserLink(t, db, database.UserLink{ + UserID: user.ID, + LoginType: database.LoginTypeOIDC, + }) + + r = httptest.NewRequest("GET", "/", nil) + rw = httptest.NewRecorder() + ) + r.Header.Set(codersdk.SessionTokenHeader, token) + + oauthToken := &oauth2.Token{ + AccessToken: "wow", + RefreshToken: "moo", + Expiry: dbtime.Now().AddDate(0, 0, 1), + } + httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ + DB: db, + OAuth2Configs: &httpmw.OAuth2Configs{ + OIDC: &testutil.OAuth2Config{ + Token: oauthToken, + }, + }, + RedirectToLogin: false, + })(successHandler).ServeHTTP(rw, r) + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + + gotAPIKey, err := db.GetAPIKeyByID(r.Context(), sentAPIKey.ID) + require.NoError(t, err) + + require.Equal(t, sentAPIKey.LastUsed, gotAPIKey.LastUsed) + require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt) + }) + t.Run("OAuthRefresh", func(t *testing.T) { t.Parallel() var ( @@ -553,7 +649,67 @@ func TestAPIKey(t *testing.T) { require.NoError(t, err) require.Equal(t, sentAPIKey.LastUsed, gotAPIKey.LastUsed) - require.Equal(t, oauthToken.Expiry, gotAPIKey.ExpiresAt) + // Note that OAuth expiry is independent of APIKey expiry, so an OIDC refresh DOES NOT affect the expiry of the + // APIKey + require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt) + + gotLink, err := db.GetUserLinkByUserIDLoginType(r.Context(), database.GetUserLinkByUserIDLoginTypeParams{ + UserID: user.ID, + LoginType: database.LoginTypeGithub, + }) + require.NoError(t, err) + require.Equal(t, gotLink.OAuthRefreshToken, "moo") + }) + + t.Run("OAuthExpiredNoRefresh", func(t *testing.T) { + t.Parallel() + var ( + ctx = testutil.Context(t, testutil.WaitShort) + db = dbmem.New() + user = dbgen.User(t, db, database.User{}) + sentAPIKey, token = dbgen.APIKey(t, db, database.APIKey{ + UserID: user.ID, + LastUsed: dbtime.Now(), + ExpiresAt: dbtime.Now().AddDate(0, 0, 1), + LoginType: database.LoginTypeGithub, + }) + + r = httptest.NewRequest("GET", "/", nil) + rw = httptest.NewRecorder() + ) + _, err := db.InsertUserLink(ctx, database.InsertUserLinkParams{ + UserID: user.ID, + LoginType: database.LoginTypeGithub, + OAuthExpiry: dbtime.Now().AddDate(0, 0, -1), + OAuthAccessToken: "letmein", + }) + require.NoError(t, err) + + r.Header.Set(codersdk.SessionTokenHeader, token) + + oauthToken := &oauth2.Token{ + AccessToken: "wow", + RefreshToken: "moo", + Expiry: dbtime.Now().AddDate(0, 0, 1), + } + httpmw.ExtractAPIKeyMW(httpmw.ExtractAPIKeyConfig{ + DB: db, + OAuth2Configs: &httpmw.OAuth2Configs{ + Github: &testutil.OAuth2Config{ + Token: oauthToken, + }, + }, + RedirectToLogin: false, + })(successHandler).ServeHTTP(rw, r) + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusUnauthorized, res.StatusCode) + + gotAPIKey, err := db.GetAPIKeyByID(r.Context(), sentAPIKey.ID) + require.NoError(t, err) + + require.Equal(t, sentAPIKey.LastUsed, gotAPIKey.LastUsed) + require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt) }) t.Run("RemoteIPUpdates", func(t *testing.T) { diff --git a/coderd/oauthpki/okidcpki_test.go b/coderd/oauthpki/okidcpki_test.go index 509da563a9145..7f7dda17bcba8 100644 --- a/coderd/oauthpki/okidcpki_test.go +++ b/coderd/oauthpki/okidcpki_test.go @@ -144,6 +144,7 @@ func TestAzureAKPKIWithCoderd(t *testing.T) { return values, nil }), oidctest.WithServing(), + oidctest.WithLogging(t, nil), ) cfg := fake.OIDCConfig(t, scopes, func(cfg *coderd.OIDCConfig) { cfg.AllowSignups = true diff --git a/site/src/theme/icons.json b/site/src/theme/icons.json index a9307bfc78446..da86c1a26c8b1 100644 --- a/site/src/theme/icons.json +++ b/site/src/theme/icons.json @@ -58,6 +58,7 @@ "javascript.svg", "jax.svg", "jetbrains-toolbox.svg", + "jetbrains.svg", "jfrog.svg", "jupyter.svg", "k8s.png", @@ -101,6 +102,7 @@ "vault.svg", "webstorm.svg", "widgets.svg", + "windows.svg", "windsurf.svg", "zed.svg" ] diff --git a/site/static/icon/jetbrains.svg b/site/static/icon/jetbrains.svg new file mode 100644 index 0000000000000..b281f962fca81 --- /dev/null +++ b/site/static/icon/jetbrains.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/site/static/icon/windows.svg b/site/static/icon/windows.svg new file mode 100644 index 0000000000000..8b774a501cdc1 --- /dev/null +++ b/site/static/icon/windows.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +