diff --git a/cmd/publisher/commands/deploy.go b/cmd/publisher/commands/deploy.go index fd327400f..4de14e650 100644 --- a/cmd/publisher/commands/deploy.go +++ b/cmd/publisher/commands/deploy.go @@ -24,6 +24,7 @@ type DeployCmd struct { SaveName string `name:"name" short:"n" help:"Save deployment with this name (in .posit/deployments/)"` Account *accounts.Account `kong:"-"` Config *config.Config `kong:"-"` + // NOTE: Currently hardcoded to insecure = false. No CLI param added for now. } func (cmd *DeployCmd) Run(args *cli_types.CommonArgs, ctx *cli_types.CLIContext) error { @@ -56,7 +57,7 @@ func (cmd *DeployCmd) Run(args *cli_types.CommonArgs, ctx *cli_types.CLIContext) if err != nil { return err } - stateStore, err := state.New(absPath, cmd.AccountName, cmd.ConfigName, "", cmd.SaveName, ctx.Accounts, nil) + stateStore, err := state.New(absPath, cmd.AccountName, cmd.ConfigName, "", cmd.SaveName, ctx.Accounts, nil, false) if err != nil { return err } diff --git a/cmd/publisher/commands/redeploy.go b/cmd/publisher/commands/redeploy.go index 44af4ad32..8b544fac9 100644 --- a/cmd/publisher/commands/redeploy.go +++ b/cmd/publisher/commands/redeploy.go @@ -23,6 +23,7 @@ type RedeployCmd struct { ConfigName string `name:"config" short:"c" help:"Configuration name (in .posit/publish/)"` Config *config.Config `kong:"-"` Target *deployment.Deployment `kong:"-"` + // NOTE: Currently hardcoded to insecure = false. No CLI param added for now. } func (cmd *RedeployCmd) Run(args *cli_types.CommonArgs, ctx *cli_types.CLIContext) error { @@ -41,7 +42,7 @@ func (cmd *RedeployCmd) Run(args *cli_types.CommonArgs, ctx *cli_types.CLIContex if err != nil { return fmt.Errorf("invalid deployment name '%s': %w", cmd.TargetName, err) } - stateStore, err := state.New(absPath, "", cmd.ConfigName, cmd.TargetName, "", ctx.Accounts, nil) + stateStore, err := state.New(absPath, "", cmd.ConfigName, cmd.TargetName, "", ctx.Accounts, nil, false) if err != nil { return err } diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 6680c69aa..70d0bdbf0 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -33,6 +33,18 @@ ], "main": "./dist/extension.js", "contributes": { + "configuration": [ + { + "title": "Posit Publisher", + "properties": { + "positPublisher.verifyCertificates": { + "markdownDescription": "Verify TLS certificates for connections. Only **disable** this setting if you experience certificate verification errors and the Posit Connect server is using a self-signed, expired, or otherwise misconfigured certificate.", + "type": "boolean", + "default": true + } + } + } + ], "commands": [ { "command": "posit.publisher.init-project", diff --git a/extensions/vscode/src/api/resources/ContentRecords.ts b/extensions/vscode/src/api/resources/ContentRecords.ts index e1d971734..4b109dc3b 100644 --- a/extensions/vscode/src/api/resources/ContentRecords.ts +++ b/extensions/vscode/src/api/resources/ContentRecords.ts @@ -71,6 +71,7 @@ export class ContentRecords { targetName: string, accountName: string, configName: string, + insecure: boolean, dir: string, secrets?: Record, ) { @@ -78,6 +79,7 @@ export class ContentRecords { account: accountName, config: configName, secrets: secrets, + insecure: insecure, }; const encodedTarget = encodeURIComponent(targetName); return this.client.post<{ localId: string }>( diff --git a/extensions/vscode/src/api/resources/Credentials.ts b/extensions/vscode/src/api/resources/Credentials.ts index b18cf104a..3e6e36c75 100644 --- a/extensions/vscode/src/api/resources/Credentials.ts +++ b/extensions/vscode/src/api/resources/Credentials.ts @@ -55,10 +55,11 @@ export class Credentials { // for valid URL and invalid API key: no user, error in TestResult // indicating that the API key is invalid // 404 - Agent not found... - test(url: string, apiKey?: string) { + test(url: string, insecure: boolean, apiKey?: string) { return this.client.post(`test-credentials`, { url, apiKey, + insecure, }); } } diff --git a/extensions/vscode/src/commands.ts b/extensions/vscode/src/commands.ts index 1332f93c9..1bdad466a 100644 --- a/extensions/vscode/src/commands.ts +++ b/extensions/vscode/src/commands.ts @@ -2,7 +2,7 @@ import * as path from "path"; -import { ExtensionContext } from "vscode"; +import { ExtensionContext, Uri } from "vscode"; import { HOST } from "src"; @@ -19,3 +19,8 @@ export const create = async ( const getExecutableBinary = (context: ExtensionContext): string => { return path.join(context.extensionPath, "bin", "publisher"); }; + +const args = ["@ext:posit.publisher"]; +export const openConfigurationCommand = Uri.parse( + `command:workbench.action.openSettings?${encodeURIComponent(JSON.stringify(args))}`, +); diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts index a5d885a5f..1f4de70e4 100644 --- a/extensions/vscode/src/extension.ts +++ b/extensions/vscode/src/extension.ts @@ -1,6 +1,6 @@ // Copyright (C) 2024 by Posit Software, PBC. -import { ExtensionContext, Uri, commands } from "vscode"; +import { ExtensionContext, Uri, commands, workspace } from "vscode"; import * as ports from "src/ports"; import { Service } from "src/services"; @@ -97,3 +97,13 @@ export async function deactivate() { await service.stop(); } } + +export const extensionSettings = { + verifyCertificates(): boolean { + // set value from extension configuration - defaults to true + const configuration = workspace.getConfiguration("positPublisher"); + const value: boolean | undefined = + configuration.get("verifyCertificates"); + return value !== undefined ? value : true; + }, +}; diff --git a/extensions/vscode/src/multiStepInputs/newCredential.ts b/extensions/vscode/src/multiStepInputs/newCredential.ts index 26bf0eadc..a778c4d39 100644 --- a/extensions/vscode/src/multiStepInputs/newCredential.ts +++ b/extensions/vscode/src/multiStepInputs/newCredential.ts @@ -17,6 +17,8 @@ import { import { formatURL, normalizeURL } from "src/utils/url"; import { checkSyntaxApiKey } from "src/utils/apiKeys"; import { showProgress } from "src/utils/progress"; +import { openConfigurationCommand } from "src/commands"; +import { extensionSettings } from "src/extension"; const createNewCredentialLabel = "Create a New Credential"; @@ -77,7 +79,6 @@ export async function newCredential( typeof state.data.url === "string" && state.data.url.length ? state.data.url : ""; - const url = await input.showInputBox({ title: state.title, step: thisStepNumber, @@ -123,16 +124,26 @@ export async function newCredential( }); } try { - const testResult = await api.credentials.test(input); + const testResult = await api.credentials.test( + input, + !extensionSettings.verifyCertificates(), // insecure = !verifyCertificates + ); if (testResult.status !== 200) { return Promise.resolve({ message: `Error: Invalid URL (unable to validate connectivity with Server URL - API Call result: ${testResult.status} - ${testResult.statusText}).`, severity: InputBoxValidationSeverity.Error, }); } - if (testResult.data.error) { + const err = testResult.data.error; + if (err) { + if (err.code === "errorCertificateVerification") { + return Promise.resolve({ + message: `Error: URL Not Accessible - ${err.msg}. If applicable, consider disabling [Verify TLS Certificates](${openConfigurationCommand}).`, + severity: InputBoxValidationSeverity.Error, + }); + } return Promise.resolve({ - message: `Error: Invalid URL (${testResult.data.error.msg}).`, + message: `Error: Invalid URL (unable to validate connectivity with Server URL - ${getMessageFromError(err)}).`, severity: InputBoxValidationSeverity.Error, }); } @@ -196,7 +207,11 @@ export async function newCredential( const serverUrl = typeof state.data.url === "string" ? state.data.url : ""; try { - const testResult = await api.credentials.test(serverUrl, input); + const testResult = await api.credentials.test( + serverUrl, + !extensionSettings.verifyCertificates(), // insecure = !verifyCertificates + input, + ); if (testResult.status !== 200) { return Promise.resolve({ message: `Error: Invalid API Key (unable to validate API Key - API Call result: ${testResult.status} - ${testResult.statusText}).`, diff --git a/extensions/vscode/src/multiStepInputs/newDeployment.ts b/extensions/vscode/src/multiStepInputs/newDeployment.ts index 7332fc397..b1c4a6012 100644 --- a/extensions/vscode/src/multiStepInputs/newDeployment.ts +++ b/extensions/vscode/src/multiStepInputs/newDeployment.ts @@ -43,6 +43,7 @@ import { DeploymentObjects } from "src/types/shared"; import { showProgress } from "src/utils/progress"; import { relativeDir, relativePath, vscodeOpenFiles } from "src/utils/files"; import { ENTRYPOINT_FILE_EXTENSIONS } from "src/constants"; +import { extensionSettings } from "src/extension"; export async function newDeployment( viewId: string, @@ -570,7 +571,10 @@ export async function newDeployment( }); } try { - const testResult = await api.credentials.test(input); + const testResult = await api.credentials.test( + input, + !extensionSettings.verifyCertificates(), // insecure = !verifyCertificates + ); if (testResult.status !== 200) { return Promise.resolve({ message: `Error: Invalid URL (unable to validate connectivity with Server URL - API Call result: ${testResult.status} - ${testResult.statusText}).`, @@ -643,7 +647,11 @@ export async function newDeployment( ? newDeploymentData.newCredentials.url : ""; try { - const testResult = await api.credentials.test(serverUrl, input); + const testResult = await api.credentials.test( + serverUrl, + !extensionSettings.verifyCertificates(), // insecure = !verifyCertificates + input, + ); if (testResult.status !== 200) { return Promise.resolve({ message: `Error: Invalid API Key (unable to validate API Key - API Call result: ${testResult.status} - ${testResult.statusText}).`, diff --git a/extensions/vscode/src/utils/errorTypes.ts b/extensions/vscode/src/utils/errorTypes.ts index 0c35a6580..9e270460f 100644 --- a/extensions/vscode/src/utils/errorTypes.ts +++ b/extensions/vscode/src/utils/errorTypes.ts @@ -8,6 +8,7 @@ export type ErrorCode = | "invalidTOML" | "unknownTOMLKey" | "invalidConfigFile" + | "errorCertificateVerification" | "deployFailed"; export type axiosErrorWithJson = diff --git a/extensions/vscode/src/views/homeView.ts b/extensions/vscode/src/views/homeView.ts index 523ed6086..f06bcd5d4 100644 --- a/extensions/vscode/src/views/homeView.ts +++ b/extensions/vscode/src/views/homeView.ts @@ -75,6 +75,7 @@ import { newCredential } from "src/multiStepInputs/newCredential"; import { PublisherState } from "src/state"; import { throttleWithLastPending } from "src/utils/throttle"; import { showAssociateGUID } from "src/actions/showAssociateGUID"; +import { extensionSettings } from "src/extension"; enum HomeViewInitialized { initialized = "initialized", @@ -259,6 +260,7 @@ export class HomeViewProvider implements WebviewViewProvider, Disposable { deploymentName, credentialName, configurationName, + !extensionSettings.verifyCertificates(), // insecure = !verifyCertificates projectDir, secrets, ); diff --git a/internal/clients/connect/client_connect.go b/internal/clients/connect/client_connect.go index 6c95dfbaf..e0e247c6f 100644 --- a/internal/clients/connect/client_connect.go +++ b/internal/clients/connect/client_connect.go @@ -3,12 +3,14 @@ package connect // Copyright (C) 2023 by Posit Software, PBC. import ( + "crypto/tls" "encoding/json" "errors" "fmt" "io" "net" "net/http" + "net/url" "regexp" "strings" "time" @@ -100,6 +102,12 @@ func isConnectAuthError(err error) bool { return errors.As(err, &serr) } +type certificationValidationFailedDetails struct { + url string + accountName string + certificateError string +} + func (c *ConnectClient) TestAuthentication(log logging.Logger) (*User, error) { log.Info("Testing authentication", "method", c.account.AuthType.Description(), "url", c.account.URL) var connectUser UserDTO @@ -111,7 +119,20 @@ func (c *ConnectClient) TestAuthentication(log logging.Logger) (*User, error) { if e, ok := err.(net.Error); ok && e.Timeout() { log.Debug("Request to Connect timed out") return nil, ErrTimedOut - } else if isConnectAuthError(err) { + } + if urlError, ok := err.(*url.Error); ok { + if certificateError, ok := urlError.Err.(*tls.CertificateVerificationError); ok { + returnErr := fmt.Errorf("unable to verify TLS certificate for server (%s)", certificateError.Err) + log.Error(returnErr.Error()) + details := &certificationValidationFailedDetails{ + url: c.account.URL, + accountName: c.account.Name, + certificateError: certificateError.Err.Error(), + } + return nil, types.NewAgentError(types.ErrorCertificateVerification, returnErr, details) + } + } + if isConnectAuthError(err) { if c.account.ApiKey != "" { // Key was provided and should have worked log.Info("Connect API key authentication check failed", "url", c.account.URL) diff --git a/internal/services/api/post_deployment.go b/internal/services/api/post_deployment.go index eefe4f8c3..8582338a0 100644 --- a/internal/services/api/post_deployment.go +++ b/internal/services/api/post_deployment.go @@ -20,6 +20,7 @@ type PostDeploymentRequestBody struct { AccountName string `json:"account"` ConfigName string `json:"config"` Secrets map[string]string `json:"secrets,omitempty"` + Insecure bool `json:"insecure"` } type PostDeploymentsReponse struct { @@ -55,7 +56,7 @@ func PostDeploymentHandlerFunc( InternalError(w, req, log, err) return } - newState, err := stateFactory(projectDir, b.AccountName, b.ConfigName, name, "", accountList, b.Secrets) + newState, err := stateFactory(projectDir, b.AccountName, b.ConfigName, name, "", accountList, b.Secrets, b.Insecure) log.Debug("New account derived state created", "account", b.AccountName, "config", b.ConfigName) if err != nil { if errors.Is(err, accounts.ErrAccountNotFound) { diff --git a/internal/services/api/post_deployment_test.go b/internal/services/api/post_deployment_test.go index 3b4590de6..3e6804b4c 100644 --- a/internal/services/api/post_deployment_test.go +++ b/internal/services/api/post_deployment_test.go @@ -67,7 +67,8 @@ func (s *PostDeploymentHandlerFuncSuite) TestPostDeploymentHandlerFunc() { req.Body = io.NopCloser(strings.NewReader( `{ "account": "local", - "config": "default" + "config": "default", + "insecure": false }`)) publisher := &mockPublisher{} @@ -79,7 +80,8 @@ func (s *PostDeploymentHandlerFuncSuite) TestPostDeploymentHandlerFunc() { path util.AbsolutePath, accountName, configName, targetName, saveName string, accountList accounts.AccountList, - secrets map[string]string) (*state.State, error) { + secrets map[string]string, + insecure bool) (*state.State, error) { s.Equal(s.cwd, path) s.Equal("myTargetName", targetName) @@ -89,6 +91,7 @@ func (s *PostDeploymentHandlerFuncSuite) TestPostDeploymentHandlerFunc() { st := state.Empty() st.Account = &accounts.Account{} + st.Account.Insecure = insecure st.Target = deployment.New() return st, nil } @@ -123,7 +126,8 @@ func (s *PostDeploymentHandlerFuncSuite) TestPostDeploymentHandlerFuncStateErr() path util.AbsolutePath, accountName, configName, targetName, saveName string, accountList accounts.AccountList, - secrets map[string]string) (*state.State, error) { + secrets map[string]string, + insecure bool) (*state.State, error) { return nil, errors.New("test error from state factory") } @@ -166,7 +170,8 @@ func (s *PostDeploymentHandlerFuncSuite) TestPostDeploymentHandlerFuncWrongServe req.Body = io.NopCloser(strings.NewReader( `{ "account": "newAcct", - "config": "default" + "config": "default", + "insecure": false }`)) handler := PostDeploymentHandlerFunc(s.cwd, log, lister, events.NewNullEmitter()) @@ -182,13 +187,14 @@ func (s *PostDeploymentHandlerFuncSuite) TestPostDeploymentHandlerFuncPublishErr s.NoError(err) lister := &accounts.MockAccountList{} - req.Body = io.NopCloser(strings.NewReader(`{"account": "local", "config": "default"}`)) + req.Body = io.NopCloser(strings.NewReader(`{"account": "local", "config": "default", "insecure": false}`)) stateFactory = func( path util.AbsolutePath, accountName, configName, targetName, saveName string, accountList accounts.AccountList, - secrets map[string]string) (*state.State, error) { + secrets map[string]string, + insecure bool) (*state.State, error) { st := state.Empty() st.Account = &accounts.Account{} @@ -229,7 +235,8 @@ func (s *PostDeploymentHandlerFuncSuite) TestPostDeploymentSubdir() { req.Body = io.NopCloser(strings.NewReader( `{ "account": "local", - "config": "default" + "config": "default", + "insecure": false }`)) publisher := &mockPublisher{} @@ -241,7 +248,8 @@ func (s *PostDeploymentHandlerFuncSuite) TestPostDeploymentSubdir() { path util.AbsolutePath, accountName, configName, targetName, saveName string, accountList accounts.AccountList, - secrets map[string]string) (*state.State, error) { + secrets map[string]string, + insecure bool) (*state.State, error) { s.Equal(s.cwd, path) s.Equal("myTargetName", targetName) @@ -273,6 +281,7 @@ func (s *PostDeploymentHandlerFuncSuite) TestPostDeploymentHandlerFuncWithSecret `{ "account": "local", "config": "default", + "insecure": false, "secrets": { "API_KEY": "secret123", "DB_PASSWORD": "password456" @@ -289,7 +298,8 @@ func (s *PostDeploymentHandlerFuncSuite) TestPostDeploymentHandlerFuncWithSecret path util.AbsolutePath, accountName, configName, targetName, saveName string, accountList accounts.AccountList, - secrets map[string]string) (*state.State, error) { + secrets map[string]string, + insecure bool) (*state.State, error) { s.Equal(s.cwd, path) s.Equal("myTargetName", targetName) diff --git a/internal/state/state.go b/internal/state/state.go index bba374405..dab7f7107 100644 --- a/internal/state/state.go +++ b/internal/state/state.go @@ -96,7 +96,7 @@ func Empty() *State { var ErrServerURLMismatch = errors.New("the account provided is for a different server; it must match the server for this deployment") -func New(path util.AbsolutePath, accountName, configName, targetName string, saveName string, accountList accounts.AccountList, secrets map[string]string) (*State, error) { +func New(path util.AbsolutePath, accountName, configName, targetName string, saveName string, accountList accounts.AccountList, secrets map[string]string, insecure bool) (*State, error) { var target *deployment.Deployment var account *accounts.Account var cfg *config.Config @@ -129,6 +129,11 @@ func New(path util.AbsolutePath, accountName, configName, targetName string, sav return nil, err } + // we don't store insecure credential flag, instead we use a + // credential-wide configuration value which is passed in. + // So we add that value before the account gets used + account.Insecure = insecure + if target.ServerURL != "" && target.ServerURL != account.URL { return nil, ErrServerURLMismatch } diff --git a/internal/state/state_test.go b/internal/state/state_test.go index 5698c03ea..b79f5e4c8 100644 --- a/internal/state/state_test.go +++ b/internal/state/state_test.go @@ -296,7 +296,7 @@ func (s *StateSuite) TestNew() { cfg := s.makeConfiguration("default") - state, err := New(s.cwd, "", "", "", "", accts, nil) + state, err := New(s.cwd, "", "", "", "", accts, nil, false) s.NoError(err) s.NotNil(state) s.Equal(state.AccountName, "") @@ -305,6 +305,7 @@ func (s *StateSuite) TestNew() { s.Equal(&acct, state.Account) s.Equal(cfg, state.Config) s.Equal(map[string]string(nil), state.Secrets) + s.Equal(state.Account.Insecure, false) // Target is never nil. We create a new target if no target ID was provided. s.NotNil(state.Target) } @@ -316,8 +317,10 @@ func (s *StateSuite) TestNewNonDefaultConfig() { configName := "staging" cfg := s.makeConfiguration(configName) + insecure := true + acct.Insecure = insecure - state, err := New(s.cwd, "", configName, "", "", accts, nil) + state, err := New(s.cwd, "", configName, "", "", accts, nil, insecure) s.NoError(err) s.NotNil(state) s.Equal("", state.AccountName) @@ -325,6 +328,7 @@ func (s *StateSuite) TestNewNonDefaultConfig() { s.Equal("", state.TargetName) s.Equal(&acct, state.Account) s.Equal(cfg, state.Config) + s.Equal(state.Account.Insecure, true) // Target is never nil. We create a new target if no target ID was provided. s.NotNil(state.Target) } @@ -334,7 +338,7 @@ func (s *StateSuite) TestNewConfigErr() { acct := accounts.Account{} accts.On("GetAllAccounts").Return([]accounts.Account{acct}, nil) - state, err := New(s.cwd, "", "", "", "", accts, nil) + state, err := New(s.cwd, "", "", "", "", accts, nil, false) s.NotNil(err) s.ErrorContains(err, "couldn't load configuration") s.Nil(state) @@ -372,7 +376,7 @@ func (s *StateSuite) TestNewWithTarget() { err := d.WriteFile(targetPath) s.NoError(err) - state, err := New(s.cwd, "", "", "myTargetName", "", accts, nil) + state, err := New(s.cwd, "", "", "myTargetName", "", accts, nil, false) s.NoError(err) s.NotNil(state) s.Equal("acct1", state.AccountName) @@ -381,6 +385,7 @@ func (s *StateSuite) TestNewWithTarget() { s.Equal(&acct1, state.Account) s.Equal(cfg, state.Config) s.Equal(d, state.Target) + s.Equal(state.Account.Insecure, false) } func (s *StateSuite) TestNewWithTargetAndAccount() { @@ -412,7 +417,7 @@ func (s *StateSuite) TestNewWithTargetAndAccount() { err := d.WriteFile(targetPath) s.NoError(err) - state, err := New(s.cwd, "acct2", "", "myTargetName", "mySaveName", accts, nil) + state, err := New(s.cwd, "acct2", "", "myTargetName", "mySaveName", accts, nil, false) s.NoError(err) s.NotNil(state) s.Equal("acct2", state.AccountName) @@ -434,7 +439,7 @@ func (s *StateSuite) TestNewWithSecrets() { "DB_PASSWORD": "password456", } - state, err := New(s.cwd, "", "", "", "", accts, secrets) + state, err := New(s.cwd, "", "", "", "", accts, secrets, false) s.NoError(err) s.NotNil(state) s.Equal(secrets, state.Secrets) @@ -450,7 +455,7 @@ func (s *StateSuite) TestNewWithInvalidSecret() { "INVALID_SECRET": "secret123", } - state, err := New(s.cwd, "", "", "", "", accts, secrets) + state, err := New(s.cwd, "", "", "", "", accts, secrets, false) s.NotNil(err) s.ErrorContains(err, "secret 'INVALID_SECRET' is not in the configuration") s.Nil(state) diff --git a/internal/types/error.go b/internal/types/error.go index 9d6e52570..cd6594729 100644 --- a/internal/types/error.go +++ b/internal/types/error.go @@ -16,6 +16,7 @@ const ( ErrorUnknownTOMLKey ErrorCode = "unknownTOMLKey" ErrorInvalidConfigFiles ErrorCode = "invalidConfigFiles" ErrorCredentialServiceUnavailable ErrorCode = "credentialsServiceUnavailable" + ErrorCertificateVerification ErrorCode = "errorCertificateVerification" ErrorUnknown ErrorCode = "unknown" )