cli: allow installation as a service from the UI (#187869)

- When turning on remote tunnel access, a quickpick is now shown asking
  users whether it should be installed as a service or just run in
	the session.
- Picking the service install will install the tunnel as a service on
  the machine, and start it.
- Turning off remote tunnel access will uninstall the service only if
  we were the ones to install it.
- This involved some refactoring to add extra state to the RemoteTunnelService.
  There's now a "mode" that includes the previous "session" and reflects
	the desired end state.
- I also did a cleanup with a `StreamSplitter` to ensure output of the
  CLI gets read line-by-line. This was depended upon by the remote tunnel
	service code, but it's not actually guaranteed.
- Changes in the CLI: allow setting the tunnel name while installing the
  service, and make both service un/installation and renames idempotent.

Closes https://github.com/microsoft/vscode/issues/184663
This commit is contained in:
Connor Peet
2023-07-13 20:23:15 -07:00
committed by GitHub
parent 7bd35446a5
commit 12340da1f1
11 changed files with 491 additions and 199 deletions

View File

@@ -649,6 +649,10 @@ pub struct TunnelServiceInstallArgs {
/// If set, the user accepts the server license terms and the server will be started without a user prompt.
#[clap(long)]
pub accept_server_license_terms: bool,
/// Sets the machine name for port forwarding service
#[clap(long)]
pub name: Option<String>,
}
#[derive(Args, Debug, Clone)]

View File

@@ -135,10 +135,17 @@ pub async fn service(
let manager = create_service_manager(ctx.log.clone(), &ctx.paths);
match service_args {
TunnelServiceSubCommands::Install(args) => {
// ensure logged in, otherwise subsequent serving will fail
Auth::new(&ctx.paths, ctx.log.clone())
.get_credential()
let auth = Auth::new(&ctx.paths, ctx.log.clone());
if let Some(name) = &args.name {
// ensure the name matches, and tunnel exists
dev_tunnels::DevTunnels::new(&ctx.log, auth, &ctx.paths)
.rename_tunnel(name)
.await?;
} else {
// still ensure they're logged in, otherwise subsequent serving will fail
auth.get_credential().await?;
}
// likewise for license consent
legal::require_consent(&ctx.paths, args.accept_server_license_terms)?;
@@ -203,20 +210,20 @@ pub async fn user(ctx: CommandContext, user_args: TunnelUserSubCommands) -> Resu
Ok(0)
}
/// Remove the tunnel used by this gateway, if any.
/// Remove the tunnel used by this tunnel, if any.
pub async fn rename(ctx: CommandContext, rename_args: TunnelRenameArgs) -> Result<i32, AnyError> {
let auth = Auth::new(&ctx.paths, ctx.log.clone());
let mut dt = dev_tunnels::DevTunnels::new(&ctx.log, auth, &ctx.paths);
dt.rename_tunnel(&rename_args.name).await?;
ctx.log.result(format!(
"Successfully renamed this gateway to {}",
"Successfully renamed this tunnel to {}",
&rename_args.name
));
Ok(0)
}
/// Remove the tunnel used by this gateway, if any.
/// Remove the tunnel used by this tunnel, if any.
pub async fn unregister(ctx: CommandContext) -> Result<i32, AnyError> {
let auth = Auth::new(&ctx.paths, ctx.log.clone());
let mut dt = dev_tunnels::DevTunnels::new(&ctx.log, auth, &ctx.paths);

View File

@@ -117,7 +117,7 @@ impl<S: Serialization, C: Send + Sync + 'static> RpcMethodBuilder<S, C> {
Ok(p) => p,
Err(err) => {
return id.map(|id| {
serial.serialize(&ErrorResponse {
serial.serialize(ErrorResponse {
id,
error: ResponseError {
code: 0,
@@ -131,7 +131,7 @@ impl<S: Serialization, C: Send + Sync + 'static> RpcMethodBuilder<S, C> {
match callback(param.params, &context) {
Ok(result) => id.map(|id| serial.serialize(&SuccessResponse { id, result })),
Err(err) => id.map(|id| {
serial.serialize(&ErrorResponse {
serial.serialize(ErrorResponse {
id,
error: ResponseError {
code: -1,
@@ -161,7 +161,7 @@ impl<S: Serialization, C: Send + Sync + 'static> RpcMethodBuilder<S, C> {
Ok(p) => p,
Err(err) => {
return future::ready(id.map(|id| {
serial.serialize(&ErrorResponse {
serial.serialize(ErrorResponse {
id,
error: ResponseError {
code: 0,
@@ -182,7 +182,7 @@ impl<S: Serialization, C: Send + Sync + 'static> RpcMethodBuilder<S, C> {
id.map(|id| serial.serialize(&SuccessResponse { id, result }))
}
Err(err) => id.map(|id| {
serial.serialize(&ErrorResponse {
serial.serialize(ErrorResponse {
id,
error: ResponseError {
code: -1,
@@ -222,7 +222,7 @@ impl<S: Serialization, C: Send + Sync + 'static> RpcMethodBuilder<S, C> {
return (
None,
future::ready(id.map(|id| {
serial.serialize(&ErrorResponse {
serial.serialize(ErrorResponse {
id,
error: ResponseError {
code: 0,
@@ -255,7 +255,7 @@ impl<S: Serialization, C: Send + Sync + 'static> RpcMethodBuilder<S, C> {
match callback(servers, param.params, context).await {
Ok(r) => id.map(|id| serial.serialize(&SuccessResponse { id, result: r })),
Err(err) => id.map(|id| {
serial.serialize(&ErrorResponse {
serial.serialize(ErrorResponse {
id,
error: ResponseError {
code: -1,
@@ -427,7 +427,7 @@ impl<S: Serialization, C: Send + Sync> RpcDispatcher<S, C> {
Some(Method::Async(callback)) => MaybeSync::Future(callback(id, body)),
Some(Method::Duplex(callback)) => MaybeSync::Stream(callback(id, body)),
None => MaybeSync::Sync(id.map(|id| {
self.serializer.serialize(&ErrorResponse {
self.serializer.serialize(ErrorResponse {
id,
error: ResponseError {
code: -1,

View File

@@ -275,7 +275,9 @@ impl DevTunnels {
/// Renames the current tunnel to the new name.
pub async fn rename_tunnel(&mut self, name: &str) -> Result<(), AnyError> {
self.update_tunnel_name(None, name).await.map(|_| ())
self.update_tunnel_name(self.launcher_tunnel.load(), name)
.await
.map(|_| ())
}
/// Updates the name of the existing persisted tunnel to the new name.
@@ -286,28 +288,34 @@ impl DevTunnels {
name: &str,
) -> Result<(Tunnel, PersistedTunnel), AnyError> {
let name = name.to_ascii_lowercase();
self.check_is_name_free(&name).await?;
debug!(self.log, "Tunnel name changed, applying updates...");
let (mut full_tunnel, mut persisted, is_new) = match persisted {
Some(persisted) => {
debug!(
self.log,
"Found a persisted tunnel, seeing if the name matches..."
);
self.get_or_create_tunnel(persisted, Some(&name), NO_REQUEST_OPTIONS)
.await
}
None => self
.create_tunnel(&name, NO_REQUEST_OPTIONS)
None => {
debug!(self.log, "Creating a new tunnel with the requested name");
self.create_tunnel(&name, NO_REQUEST_OPTIONS)
.await
.map(|(pt, t)| (t, pt, true)),
.map(|(pt, t)| (t, pt, true))
}
}?;
if is_new {
let desired_tags = self.get_tags(&name);
if is_new || vec_eq_as_set(&full_tunnel.tags, &desired_tags) {
return Ok((full_tunnel, persisted));
}
full_tunnel.tags = self.get_tags(&name);
debug!(self.log, "Tunnel name changed, applying updates...");
let new_tunnel = spanf!(
full_tunnel.tags = desired_tags;
let updated_tunnel = spanf!(
self.log,
self.log.span("dev-tunnel.tag.update"),
self.client.update_tunnel(&full_tunnel, NO_REQUEST_OPTIONS)
@@ -317,7 +325,7 @@ impl DevTunnels {
persisted.name = name;
self.launcher_tunnel.save(Some(persisted.clone()))?;
Ok((new_tunnel, persisted))
Ok((updated_tunnel, persisted))
}
/// Gets the persisted tunnel from the service, or creates a new one.
@@ -443,6 +451,8 @@ impl DevTunnels {
) -> Result<(PersistedTunnel, Tunnel), AnyError> {
info!(self.log, "Creating tunnel with the name: {}", name);
self.check_is_name_free(name).await?;
let mut tried_recycle = false;
let new_tunnel = Tunnel {
@@ -527,7 +537,7 @@ impl DevTunnels {
options: &TunnelRequestOptions,
) -> Result<Tunnel, AnyError> {
let new_tags = self.get_tags(name);
if vec_eq_unsorted(&tunnel.tags, &new_tags) {
if vec_eq_as_set(&tunnel.tags, &new_tags) {
return Ok(tunnel);
}
@@ -610,7 +620,7 @@ impl DevTunnels {
}
async fn check_is_name_free(&mut self, name: &str) -> Result<(), AnyError> {
let existing = spanf!(
let existing: Vec<Tunnel> = spanf!(
self.log,
self.log.span("dev-tunnel.rename.search"),
self.client.list_all_tunnels(&TunnelRequestOptions {
@@ -998,7 +1008,7 @@ fn clean_hostname_for_tunnel(hostname: &str) -> String {
}
}
fn vec_eq_unsorted(a: &[String], b: &[String]) -> bool {
fn vec_eq_as_set(a: &[String], b: &[String]) -> bool {
if a.len() != b.len() {
return false;
}

View File

@@ -78,6 +78,7 @@ impl CliServiceManager for WindowsService {
cmd.stderr(Stdio::null());
cmd.stdout(Stdio::null());
cmd.stdin(Stdio::null());
cmd.creation_flags(CREATE_NEW_PROCESS_GROUP | DETACHED_PROCESS);
cmd.spawn()
.map_err(|e| wrapdbg(e, "error starting service"))?;
@@ -121,8 +122,12 @@ impl CliServiceManager for WindowsService {
async fn unregister(&self) -> Result<(), AnyError> {
let key = WindowsService::open_key()?;
key.delete_value(TUNNEL_ACTIVITY_NAME)
.map_err(|e| AnyError::from(wrap(e, "error deleting registry key")))?;
match key.delete_value(TUNNEL_ACTIVITY_NAME) {
Ok(_) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(wrap(e, "error deleting registry key").into()),
}
info!(self.log, "Tunnel service uninstalled");
let r = do_single_rpc_call::<_, ()>(

View File

@@ -172,10 +172,17 @@ export class VSBuffer {
writeUInt8(this.buffer, value, offset);
}
indexOf(subarray: VSBuffer | Uint8Array) {
const needle = subarray instanceof VSBuffer ? subarray.buffer : subarray;
indexOf(subarray: VSBuffer | Uint8Array, offset = 0) {
return binaryIndexOf(this.buffer, subarray instanceof VSBuffer ? subarray.buffer : subarray, offset);
}
}
/**
* Like String.indexOf, but works on Uint8Arrays.
* Uses the boyer-moore-horspool algorithm to be reasonably speedy.
*/
export function binaryIndexOf(haystack: Uint8Array, needle: Uint8Array, offset = 0): number {
const needleLen = needle.byteLength;
const haystack = this.buffer;
const haystackLen = haystack.byteLength;
if (needleLen === 0) {
@@ -186,7 +193,7 @@ export class VSBuffer {
return haystack.indexOf(needle[0]);
}
if (needleLen > haystackLen) {
if (needleLen > haystackLen - offset) {
return -1;
}
@@ -197,7 +204,7 @@ export class VSBuffer {
table[needle[i]] = needle.length - i - 1;
}
let i = needle.length - 1;
let i = offset + needle.length - 1;
let j = i;
let result = -1;
while (i < haystackLen) {
@@ -216,7 +223,6 @@ export class VSBuffer {
}
return result;
}
}
export function readUInt16LE(source: Uint8Array, offset: number): number {

View File

@@ -0,0 +1,62 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Transform } from 'stream';
import { binaryIndexOf } from 'vs/base/common/buffer';
/**
* A Transform stream that splits the input on the "splitter" substring.
* The resulting chunks will contain (and trail with) the splitter match.
* The last chunk when the stream ends will be emitted even if a splitter
* is not encountered.
*/
export class StreamSplitter extends Transform {
private buffer: Buffer | undefined;
private readonly splitter: Buffer | number;
private readonly spitterLen: number;
constructor(splitter: string | number | Buffer) {
super();
if (typeof splitter === 'number') {
this.splitter = splitter;
this.spitterLen = 1;
} else {
const buf = Buffer.isBuffer(splitter) ? splitter : Buffer.from(splitter);
this.splitter = buf.length === 1 ? buf[0] : buf;
this.spitterLen = buf.length;
}
}
override _transform(chunk: Buffer, _encoding: string, callback: (error?: Error | null, data?: any) => void): void {
if (!this.buffer) {
this.buffer = chunk;
} else {
this.buffer = Buffer.concat([this.buffer, chunk]);
}
let offset = 0;
while (offset < this.buffer.length) {
const index = typeof this.splitter === 'number'
? this.buffer.indexOf(this.splitter, offset)
: binaryIndexOf(this.buffer, this.splitter, offset);
if (index === -1) {
break;
}
this.push(this.buffer.slice(offset, index + this.spitterLen));
offset = index + this.spitterLen;
}
this.buffer = offset === this.buffer.length ? undefined : this.buffer.slice(offset);
callback();
}
override _flush(callback: (error?: Error | null, data?: any) => void): void {
if (this.buffer) {
this.push(this.buffer);
}
callback();
}
}

View File

@@ -0,0 +1,51 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Writable } from 'stream';
import * as assert from 'assert';
import { StreamSplitter } from 'vs/base/node/nodeStreams';
suite('StreamSplitter', () => {
test('should split a stream on a single character splitter', (done) => {
const chunks: string[] = [];
const splitter = new StreamSplitter('\n');
const writable = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk.toString());
callback();
},
});
splitter.pipe(writable);
splitter.write('hello\nwor');
splitter.write('ld\n');
splitter.write('foo\nbar\nz');
splitter.end(() => {
assert.deepStrictEqual(chunks, ['hello\n', 'world\n', 'foo\n', 'bar\n', 'z']);
done();
});
});
test('should split a stream on a multi-character splitter', (done) => {
const chunks: string[] = [];
const splitter = new StreamSplitter('---');
const writable = new Writable({
write(chunk, _encoding, callback) {
chunks.push(chunk.toString());
callback();
},
});
splitter.pipe(writable);
splitter.write('hello---wor');
splitter.write('ld---');
splitter.write('foo---bar---z');
splitter.end(() => {
assert.deepStrictEqual(chunks, ['hello---', 'world---', 'foo---', 'bar---', 'z']);
done();
});
});
});

View File

@@ -21,18 +21,33 @@ export interface IRemoteTunnelService {
readonly onDidChangeTunnelStatus: Event<TunnelStatus>;
getTunnelStatus(): Promise<TunnelStatus>;
getSession(): Promise<IRemoteTunnelSession | undefined>;
readonly onDidChangeSession: Event<IRemoteTunnelSession | undefined>;
getMode(): Promise<TunnelMode>;
readonly onDidChangeMode: Event<TunnelMode>;
readonly onDidTokenFailed: Event<IRemoteTunnelSession | undefined>;
initialize(session: IRemoteTunnelSession | undefined): Promise<TunnelStatus>;
initialize(mode: TunnelMode): Promise<TunnelStatus>;
startTunnel(session: IRemoteTunnelSession): Promise<TunnelStatus>;
startTunnel(mode: ActiveTunnelMode): Promise<TunnelStatus>;
stopTunnel(): Promise<void>;
getTunnelName(): Promise<string | undefined>;
}
export interface ActiveTunnelMode {
readonly active: true;
readonly session: IRemoteTunnelSession;
readonly asService: boolean;
}
export interface InactiveTunnelMode {
readonly active: false;
}
export const INACTIVE_TUNNEL_MODE: InactiveTunnelMode = { active: false };
/** Saved mode for the tunnel. */
export type TunnelMode = ActiveTunnelMode | InactiveTunnelMode;
export type TunnelStatus = TunnelStates.Connected | TunnelStates.Disconnected | TunnelStates.Connecting | TunnelStates.Uninitialized;
export namespace TunnelStates {
@@ -46,13 +61,14 @@ export namespace TunnelStates {
export interface Connected {
readonly type: 'connected';
readonly info: ConnectionInfo;
readonly serviceInstallFailed: boolean;
}
export interface Disconnected {
readonly type: 'disconnected';
readonly onTokenFailed?: IRemoteTunnelSession;
}
export const disconnected = (onTokenFailed?: IRemoteTunnelSession): Disconnected => ({ type: 'disconnected', onTokenFailed });
export const connected = (info: ConnectionInfo): Connected => ({ type: 'connected', info });
export const connected = (info: ConnectionInfo, serviceInstallFailed: boolean): Connected => ({ type: 'connected', info, serviceInstallFailed });
export const connecting = (progress?: string): Connecting => ({ type: 'connecting', progress });
export const uninitialized: Uninitialized = { type: 'uninitialized' };

View File

@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { CONFIGURATION_KEY_HOST_NAME, CONFIGURATION_KEY_PREVENT_SLEEP, ConnectionInfo, IRemoteTunnelSession, IRemoteTunnelService, LOGGER_NAME, LOG_ID, TunnelStates, TunnelStatus } from 'vs/platform/remoteTunnel/common/remoteTunnel';
import { CONFIGURATION_KEY_HOST_NAME, CONFIGURATION_KEY_PREVENT_SLEEP, ConnectionInfo, IRemoteTunnelSession, IRemoteTunnelService, LOGGER_NAME, LOG_ID, TunnelStates, TunnelStatus, TunnelMode, INACTIVE_TUNNEL_MODE, ActiveTunnelMode } from 'vs/platform/remoteTunnel/common/remoteTunnel';
import { Emitter } from 'vs/base/common/event';
import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry';
import { INativeEnvironmentService } from 'vs/platform/environment/common/environment';
@@ -20,15 +20,18 @@ import { localize } from 'vs/nls';
import { hostname, homedir } from 'os';
import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
import { isString } from 'vs/base/common/types';
import { StreamSplitter } from 'vs/base/node/nodeStreams';
type RemoteTunnelEnablementClassification = {
owner: 'aeschli';
comment: 'Reporting when Remote Tunnel access is turned on or off';
enabled?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Flag indicating if Remote Tunnel Access is enabled or not' };
service?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Flag indicating if Remote Tunnel Access is installed as a service' };
};
type RemoteTunnelEnablementEvent = {
enabled: boolean;
service: boolean;
};
const restartTunnelOnConfigurationChanges: readonly string[] = [
@@ -40,6 +43,8 @@ const restartTunnelOnConfigurationChanges: readonly string[] = [
// if set, the remote tunnel access is currently enabled.
// if not set, the remote tunnel access is currently disabled.
const TUNNEL_ACCESS_SESSION = 'remoteTunnelSession';
// Boolean indicating whether the tunnel should be installed as a service.
const TUNNEL_ACCESS_IS_SERVICE = 'remoteTunnelIsService';
/**
* This service runs on the shared service. It is running the `code-tunnel` command
@@ -55,12 +60,19 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
private readonly _onDidChangeTunnelStatusEmitter = new Emitter<TunnelStatus>();
public readonly onDidChangeTunnelStatus = this._onDidChangeTunnelStatusEmitter.event;
private readonly _onDidChangeSessionEmitter = new Emitter<IRemoteTunnelSession | undefined>();
public readonly onDidChangeSession = this._onDidChangeSessionEmitter.event;
private readonly _onDidChangeModeEmitter = new Emitter<TunnelMode>();
public readonly onDidChangeMode = this._onDidChangeModeEmitter.event;
private readonly _logger: ILogger;
private _session: IRemoteTunnelSession | undefined;
/**
* "Mode" in the terminal state we want to get to -- started, stopped, and
* the attributes associated with each.
*
* At any given time, work may be ongoing to get `_tunnelStatus` into a
* state that reflects the desired `mode`.
*/
private _mode: TunnelMode = INACTIVE_TUNNEL_MODE;
private _tunnelProcess: CancelablePromise<any> | undefined;
@@ -98,7 +110,7 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
}
}));
this._session = this._restoreSession();
this._mode = this._restoreMode();
this._tunnelStatus = TunnelStates.uninitialized;
}
@@ -111,32 +123,34 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
this._onDidChangeTunnelStatusEmitter.fire(tunnelStatus);
}
private setSession(session: IRemoteTunnelSession | undefined) {
if (!isSameSession(session, this._session)) {
this._session = session;
this._onDidChangeSessionEmitter.fire(session);
this._storeSession(session);
if (session) {
this._logger.info(`Session updated: ${session.accountLabel} (${session.providerId})`);
if (session.token) {
this._logger.info(`Session token updated: ${session.accountLabel} (${session.providerId})`);
private setMode(mode: TunnelMode) {
if (isSameMode(this._mode, mode)) {
return;
}
this._mode = mode;
this._storeMode(mode);
this._onDidChangeModeEmitter.fire(this._mode);
if (mode.active) {
this._logger.info(`Session updated: ${mode.session.accountLabel} (${mode.session.providerId}) (service=${mode.asService})`);
if (mode.session.token) {
this._logger.info(`Session token updated: ${mode.session.accountLabel} (${mode.session.providerId})`);
}
} else {
this._logger.info(`Session reset`);
}
}
getMode(): Promise<TunnelMode> {
return Promise.resolve(this._mode);
}
async getSession(): Promise<IRemoteTunnelSession | undefined> {
return this._session;
}
async initialize(session: IRemoteTunnelSession | undefined): Promise<TunnelStatus> {
async initialize(mode: TunnelMode): Promise<TunnelStatus> {
if (this._initialized) {
return this._tunnelStatus;
}
this._initialized = true;
this.setSession(session);
this.setMode(mode);
try {
await this._startTunnelProcessDelayer.trigger(() => this.updateTunnelProcess());
} catch (e) {
@@ -145,6 +159,14 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
return this._tunnelStatus;
}
private readonly defaultOnOutput = (a: string, isErr: boolean) => {
if (isErr) {
this._logger.error(a);
} else {
this._logger.info(a);
}
};
private getTunnelCommandLocation() {
if (!this._tunnelCommand) {
let binParentLocation;
@@ -164,11 +186,12 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
return this._tunnelCommand;
}
async startTunnel(session: IRemoteTunnelSession): Promise<TunnelStatus> {
if (isSameSession(session, this._session) && this._tunnelStatus.type !== 'disconnected') {
async startTunnel(mode: ActiveTunnelMode): Promise<TunnelStatus> {
if (isSameMode(this._mode, mode) && this._tunnelStatus.type !== 'disconnected') {
return this._tunnelStatus;
}
this.setSession(session);
this.setMode(mode);
try {
await this._startTunnelProcessDelayer.trigger(() => this.updateTunnelProcess());
@@ -180,41 +203,49 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
async stopTunnel(): Promise<void> {
this.setSession(undefined);
if (this._tunnelProcess) {
this._tunnelProcess.cancel();
this._tunnelProcess = undefined;
}
const onOutput = (a: string, isErr: boolean) => {
if (isErr) {
this._logger.error(a);
} else {
this._logger.info(a);
if (!this._mode.active) {
return;
}
};
// Be careful to only uninstall the service if we're the ones who installed it:
const needsServiceUninstall = this._mode.asService;
this.setMode(INACTIVE_TUNNEL_MODE);
try {
await this.runCodeTunnelCommand('stop', ['kill'], onOutput);
if (needsServiceUninstall) {
this.runCodeTunnelCommand('uninstallService', ['service', 'uninstall']);
}
} catch (e) {
this._logger.error(e);
}
this.setTunnelStatus(TunnelStates.disconnected());
try {
await this.runCodeTunnelCommand('stop', ['kill']);
} catch (e) {
this._logger.error(e);
}
this.setTunnelStatus(TunnelStates.disconnected());
}
private async updateTunnelProcess(): Promise<void> {
this.telemetryService.publicLog2<RemoteTunnelEnablementEvent, RemoteTunnelEnablementClassification>('remoteTunnel.enablement', { enabled: !!this._session });
this.telemetryService.publicLog2<RemoteTunnelEnablementEvent, RemoteTunnelEnablementClassification>('remoteTunnel.enablement', {
enabled: this._mode.active,
service: this._mode.active && this._mode.asService,
});
if (this._tunnelProcess) {
this._tunnelProcess.cancel();
this._tunnelProcess = undefined;
}
let isAttached = false;
let output = '';
let isServiceInstalled = false;
const onOutput = (a: string, isErr: boolean) => {
if (isErr) {
this._logger.error(a);
@@ -241,22 +272,26 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
tunnel: object | null;
} = JSON.parse(output.trim().split('\n').find(l => l.startsWith('{'))!);
isAttached = !!status.tunnel;
this._logger.info(isAttached ? 'Other tunnel running, attaching...' : 'No other tunnel running');
if (!isAttached && !this._session) {
this._tunnelProcess = undefined;
isServiceInstalled = status.service_installed;
this._logger.info(status.tunnel ? 'Other tunnel running, attaching...' : 'No other tunnel running');
// If a tunnel is running but the mode isn't "active", we'll still attach
// to the tunnel to show its state in the UI. If neither are true, disconnect
if (!status.tunnel && !this._mode.active) {
this.setTunnelStatus(TunnelStates.disconnected());
return;
}
} catch (e) {
this._logger.error(e);
this._tunnelProcess = undefined;
this.setTunnelStatus(TunnelStates.disconnected());
return;
} finally {
if (this._tunnelProcess === statusProcess) {
this._tunnelProcess = undefined;
}
}
const session = this._session;
const session = this._mode.active ? this._mode.session : undefined;
if (session && session.token) {
const token = session.token;
this.setTunnelStatus(TunnelStates.connecting(localize({ key: 'remoteTunnelService.authorizing', comment: ['{0} is a user account name, {1} a provider name (e.g. Github)'] }, 'Connecting as {0} ({1})', session.accountLabel, session.providerId)));
@@ -286,25 +321,67 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
} else {
this.setTunnelStatus(TunnelStates.connecting(localize('remoteTunnelService.openTunnel', 'Opening tunnel')));
}
const args = ['--parent-process-id', String(process.pid), '--accept-server-license-terms', '--log', LogLevelToString(this._logger.getLevel())];
const args = ['--accept-server-license-terms', '--log', LogLevelToString(this._logger.getLevel())];
if (hostName) {
args.push('--name', hostName);
} else {
args.push('--random-name');
}
let serviceInstallFailed = false;
if (this._mode.active && this._mode.asService && !isServiceInstalled) {
// I thought about calling `code tunnel kill` here, but having multiple
// tunnel processes running is pretty much idempotent. If there's
// another tunnel process running, the service process will
// take over when it exits, no hard feelings.
serviceInstallFailed = await this.installTunnelService(args) === false;
}
return this.serverOrAttachTunnel(session, args, serviceInstallFailed);
}
private async installTunnelService(args: readonly string[]) {
let status: number;
try {
status = await this.runCodeTunnelCommand('serviceInstall', ['service', 'install', ...args]);
} catch (e) {
this._logger.error(e);
status = 1;
}
if (status !== 0) {
const msg = localize('remoteTunnelService.serviceInstallFailed', 'Failed to install tunnel as a service, starting in session...');
this._logger.warn(msg);
this.setTunnelStatus(TunnelStates.connecting(msg));
return false;
}
return true;
}
private async serverOrAttachTunnel(session: IRemoteTunnelSession | undefined, args: string[], serviceInstallFailed: boolean) {
args.push('--parent-process-id', String(process.pid));
if (this._preventSleep()) {
args.push('--no-sleep');
}
let isAttached = false;
const serveCommand = this.runCodeTunnelCommand('tunnel', args, (message: string, isErr: boolean) => {
if (isErr) {
this._logger.error(message);
} else {
this._logger.info(message);
}
if (message.includes('Connected to an existing tunnel process')) {
isAttached = true;
}
const m = message.match(/Open this link in your browser (https:\/\/([^\/\s]+)\/([^\/\s]+)\/([^\/\s]+))/);
if (m) {
const info: ConnectionInfo = { link: m[1], domain: m[2], tunnelName: m[4], isAttached };
this.setTunnelStatus(TunnelStates.connected(info));
this.setTunnelStatus(TunnelStates.connected(info, serviceInstallFailed));
} else if (message.match(/error refreshing token/)) {
serveCommand.cancel();
this._onDidTokenFailedEmitter.fire(session);
@@ -317,14 +394,14 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
// process exited unexpectedly
this._logger.info(`tunnel process terminated`);
this._tunnelProcess = undefined;
this._session = undefined;
this._mode = INACTIVE_TUNNEL_MODE;
this.setTunnelStatus(TunnelStates.disconnected());
}
});
}
private runCodeTunnelCommand(logLabel: string, commandArgs: string[], onOutput: (message: string, isError: boolean) => void = () => { }): CancelablePromise<number> {
private runCodeTunnelCommand(logLabel: string, commandArgs: string[], onOutput: (message: string, isError: boolean) => void = this.defaultOnOutput): CancelablePromise<number> {
return createCancelablePromise<number>(token => {
return new Promise((resolve, reject) => {
if (token.isCancellationRequested) {
@@ -350,13 +427,13 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
tunnelProcess = spawn(tunnelCommand, ['tunnel', ...commandArgs], { cwd: homedir(), stdio });
}
tunnelProcess.stdout!.on('data', data => {
tunnelProcess.stdout!.pipe(new StreamSplitter('\n')).on('data', data => {
if (tunnelProcess) {
const message = data.toString();
onOutput(message, false);
}
});
tunnelProcess.stderr!.on('data', data => {
tunnelProcess.stderr!.pipe(new StreamSplitter('\n')).on('data', data => {
if (tunnelProcess) {
const message = data.toString();
onOutput(message, true);
@@ -394,30 +471,33 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ
return name || undefined;
}
private _restoreSession(): IRemoteTunnelSession | undefined {
private _restoreMode(): TunnelMode {
try {
const tunnelAccessSession = this.storageService.get(TUNNEL_ACCESS_SESSION, StorageScope.APPLICATION);
const asService = this.storageService.getBoolean(TUNNEL_ACCESS_IS_SERVICE, StorageScope.APPLICATION, false);
if (tunnelAccessSession) {
const session = JSON.parse(tunnelAccessSession) as IRemoteTunnelSession;
if (session && isString(session.accountLabel) && isString(session.sessionId) && isString(session.providerId)) {
return session;
return { active: true, session, asService };
}
this._logger.error('Problems restoring session from storage, invalid format', session);
}
} catch (e) {
this._logger.error('Problems restoring session from storage', e);
}
return undefined;
return INACTIVE_TUNNEL_MODE;
}
private _storeSession(session: IRemoteTunnelSession | undefined): void {
if (session) {
private _storeMode(mode: TunnelMode): void {
if (mode.active) {
const sessionWithoutToken = {
providerId: session.providerId, sessionId: session.sessionId, accountLabel: session.accountLabel
providerId: mode.session.providerId, sessionId: mode.session.sessionId, accountLabel: mode.session.accountLabel
};
this.storageService.store(TUNNEL_ACCESS_SESSION, JSON.stringify(sessionWithoutToken), StorageScope.APPLICATION, StorageTarget.MACHINE);
this.storageService.store(TUNNEL_ACCESS_IS_SERVICE, mode.asService, StorageScope.APPLICATION, StorageTarget.MACHINE);
} else {
this.storageService.remove(TUNNEL_ACCESS_SESSION, StorageScope.APPLICATION);
this.storageService.remove(TUNNEL_ACCESS_IS_SERVICE, StorageScope.APPLICATION);
}
}
}
@@ -429,3 +509,12 @@ function isSameSession(a1: IRemoteTunnelSession | undefined, a2: IRemoteTunnelSe
return a1 === a2;
}
const isSameMode = (a: TunnelMode, b: TunnelMode) => {
if (a.active !== b.active) {
return false;
} else if (a.active && b.active) {
return a.asService === b.asService && isSameSession(a.session, b.session);
} else {
return true;
}
};

View File

@@ -3,40 +3,39 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Disposable, DisposableStore } from 'vs/base/common/lifecycle';
import { Action2, MenuId, registerAction2 } from 'vs/platform/actions/common/actions';
import { IProductService } from 'vs/platform/product/common/productService';
import { CONFIGURATION_KEY_HOST_NAME, CONFIGURATION_KEY_PREFIX, CONFIGURATION_KEY_PREVENT_SLEEP, ConnectionInfo, IRemoteTunnelSession, IRemoteTunnelService, LOGGER_NAME, LOG_ID } from 'vs/platform/remoteTunnel/common/remoteTunnel';
import { AuthenticationSession, IAuthenticationService } from 'vs/workbench/services/authentication/common/authentication';
import { localize } from 'vs/nls';
import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions, IWorkbenchContribution } from 'vs/workbench/common/contributions';
import { Registry } from 'vs/platform/registry/common/platform';
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';
import { ILocalizedString } from 'vs/platform/action/common/action';
import { IDialogService } from 'vs/platform/dialogs/common/dialogs';
import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
import { ILogger, ILoggerService, ILogService } from 'vs/platform/log/common/log';
import { INativeEnvironmentService } from 'vs/platform/environment/common/environment';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
import { IQuickInputService, IQuickPickItem, IQuickPickSeparator, QuickPickItem } from 'vs/platform/quickinput/common/quickInput';
import { IOutputService } from 'vs/workbench/services/output/common/output';
import { IFileService } from 'vs/platform/files/common/files';
import { IConfigurationRegistry, Extensions as ConfigurationExtensions, ConfigurationScope } from 'vs/platform/configuration/common/configurationRegistry';
import { IProgress, IProgressService, IProgressStep, ProgressLocation } from 'vs/platform/progress/common/progress';
import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
import { INotificationService, Severity } from 'vs/platform/notification/common/notification';
import { ICommandService } from 'vs/platform/commands/common/commands';
import { IPreferencesService } from 'vs/workbench/services/preferences/common/preferences';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { Action } from 'vs/base/common/actions';
import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService';
import { IWorkspaceContextService, isUntitledWorkspace } from 'vs/platform/workspace/common/workspace';
import { Disposable, DisposableStore } from 'vs/base/common/lifecycle';
import { Schemas } from 'vs/base/common/network';
import { URI } from 'vs/base/common/uri';
import { joinPath } from 'vs/base/common/resources';
import { ITunnelApplicationConfig } from 'vs/base/common/product';
import { joinPath } from 'vs/base/common/resources';
import { isNumber, isObject, isString } from 'vs/base/common/types';
import { URI } from 'vs/base/common/uri';
import { localize } from 'vs/nls';
import { ILocalizedString } from 'vs/platform/action/common/action';
import { Action2, MenuId, registerAction2 } from 'vs/platform/actions/common/actions';
import { IClipboardService } from 'vs/platform/clipboard/common/clipboardService';
import { ICommandService } from 'vs/platform/commands/common/commands';
import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurationRegistry } from 'vs/platform/configuration/common/configurationRegistry';
import { ContextKeyExpr, IContextKey, IContextKeyService, RawContextKey } from 'vs/platform/contextkey/common/contextkey';
import { IDialogService } from 'vs/platform/dialogs/common/dialogs';
import { INativeEnvironmentService } from 'vs/platform/environment/common/environment';
import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation';
import { ILogger, ILoggerService } from 'vs/platform/log/common/log';
import { INotificationService, Severity } from 'vs/platform/notification/common/notification';
import { IOpenerService } from 'vs/platform/opener/common/opener';
import { IProductService } from 'vs/platform/product/common/productService';
import { IProgress, IProgressService, IProgressStep, ProgressLocation } from 'vs/platform/progress/common/progress';
import { IQuickInputService, IQuickPickItem, IQuickPickSeparator, QuickPickItem } from 'vs/platform/quickinput/common/quickInput';
import { Registry } from 'vs/platform/registry/common/platform';
import { CONFIGURATION_KEY_HOST_NAME, CONFIGURATION_KEY_PREFIX, CONFIGURATION_KEY_PREVENT_SLEEP, ConnectionInfo, INACTIVE_TUNNEL_MODE, IRemoteTunnelService, IRemoteTunnelSession, LOGGER_NAME, LOG_ID, TunnelStatus } from 'vs/platform/remoteTunnel/common/remoteTunnel';
import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage';
import { IWorkspaceContextService, isUntitledWorkspace } from 'vs/platform/workspace/common/workspace';
import { IWorkbenchContribution, IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } from 'vs/workbench/common/contributions';
import { AuthenticationSession, IAuthenticationService } from 'vs/workbench/services/authentication/common/authentication';
import { IExtensionService } from 'vs/workbench/services/extensions/common/extensions';
import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle';
import { IOutputService } from 'vs/workbench/services/output/common/output';
import { IPreferencesService } from 'vs/workbench/services/preferences/common/preferences';
export const REMOTE_TUNNEL_CATEGORY: ILocalizedString = {
original: 'Remote-Tunnels',
@@ -103,10 +102,8 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
@IProductService productService: IProductService,
@IStorageService private readonly storageService: IStorageService,
@ILoggerService loggerService: ILoggerService,
@ILogService logService: ILogService,
@IQuickInputService private readonly quickInputService: IQuickInputService,
@INativeEnvironmentService private environmentService: INativeEnvironmentService,
@IFileService fileService: IFileService,
@IRemoteTunnelService private remoteTunnelService: IRemoteTunnelService,
@ICommandService private commandService: ICommandService,
@IWorkspaceContextService private workspaceContextService: IWorkspaceContextService,
@@ -127,7 +124,16 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
}
this.serverConfiguration = serverConfiguration;
this._register(this.remoteTunnelService.onDidChangeTunnelStatus(status => {
this._register(this.remoteTunnelService.onDidChangeTunnelStatus(s => this.handleTunnelStatusUpdate(s)));
this.registerCommands();
this.initialize();
this.recommendRemoteExtensionIfNeeded();
}
private handleTunnelStatusUpdate(status: TunnelStatus) {
this.connectionInfo = undefined;
if (status.type === 'disconnected') {
if (status.onTokenFailed) {
@@ -140,13 +146,6 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
this.connectionInfo = status.info;
this.connectionStateContext.set('connected');
}
}));
this.registerCommands();
this.initialize();
this.recommendRemoteExtensionIfNeeded();
}
private async recommendRemoteExtensionIfNeeded() {
@@ -228,10 +227,17 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
}
private async initialize(): Promise<void> {
const session = await this.remoteTunnelService.getSession();
if (session && session.token) {
const [mode, status] = await Promise.all([
this.remoteTunnelService.getMode(),
this.remoteTunnelService.getTunnelStatus(),
]);
this.handleTunnelStatusUpdate(status);
if (mode.active && mode.session.token) {
return; // already initialized, token available
}
return await this.progressService.withProgress(
{
location: ProgressLocation.Window,
@@ -248,13 +254,13 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
}
});
let newSession: IRemoteTunnelSession | undefined;
if (session) {
const token = await this.getSessionToken(session);
if (mode.active) {
const token = await this.getSessionToken(mode.session);
if (token) {
newSession = { ...session, token };
newSession = { ...mode.session, token };
}
}
const status = await this.remoteTunnelService.initialize(newSession);
const status = await this.remoteTunnelService.initialize(mode.active && newSession ? { ...mode, session: newSession } : INACTIVE_TUNNEL_MODE);
listener.dispose();
if (status.type === 'connected') {
@@ -267,7 +273,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
}
private async startTunnel(): Promise<ConnectionInfo | undefined> {
private async startTunnel(asService: boolean): Promise<ConnectionInfo | undefined> {
if (this.connectionInfo) {
return this.connectionInfo;
}
@@ -301,6 +307,19 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
listener.dispose();
completed = true;
s(status.info);
if (status.serviceInstallFailed) {
this.notificationService.notify({
severity: Severity.Warning,
message: localize(
{
key: 'remoteTunnel.serviceInstallFailed',
comment: ['{Locked="](command:{0})"}']
},
"Installation as a service failed, and we fell back to running the tunnel for this session. See the [error log](command:{0}) for details.",
RemoteTunnelCommandIds.showLog,
),
});
}
break;
case 'disconnected':
listener.dispose();
@@ -312,7 +331,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
});
const token = authenticationSession.session.idToken ?? authenticationSession.session.accessToken;
const account: IRemoteTunnelSession = { sessionId: authenticationSession.session.id, token, providerId: authenticationSession.providerId, accountLabel: authenticationSession.session.account.label };
this.remoteTunnelService.startTunnel(account).then(status => {
this.remoteTunnelService.startTunnel({ active: true, asService, session: account }).then(status => {
if (!completed && (status.type === 'connected' || status.type === 'disconnected')) {
listener.dispose();
if (status.type === 'connected') {
@@ -403,7 +422,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
private async getAllSessions(): Promise<ExistingSessionItem[]> {
const authenticationProviders = await this.getAuthenticationProviders();
const accounts = new Map<string, ExistingSessionItem>();
const currentAccount = await this.remoteTunnelService.getSession();
const currentAccount = await this.remoteTunnelService.getMode();
let currentSession: ExistingSessionItem | undefined;
for (const provider of authenticationProviders) {
@@ -413,7 +432,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
if (!this.expiredSessions.has(session.id)) {
const item = this.createExistingSessionItem(session, provider.id);
accounts.set(item.session.account.id, item);
if (currentAccount && currentAccount.sessionId === session.id) {
if (currentAccount.active && currentAccount.session.sessionId === session.id) {
currentSession = item;
}
}
@@ -483,6 +502,8 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
const commandService = accessor.get(ICommandService);
const storageService = accessor.get(IStorageService);
const dialogService = accessor.get(IDialogService);
const quickInputService = accessor.get(IQuickInputService);
const productService = accessor.get(IProductService);
const didNotifyPreview = storageService.getBoolean(REMOTE_TUNNEL_PROMPTED_PREVIEW_STORAGE_KEY, StorageScope.APPLICATION, false);
if (!didNotifyPreview) {
@@ -497,12 +518,33 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
storageService.store(REMOTE_TUNNEL_PROMPTED_PREVIEW_STORAGE_KEY, true, StorageScope.APPLICATION, StorageTarget.USER);
}
const connectionInfo = await that.startTunnel();
const disposables = new DisposableStore();
const quickPick = quickInputService.createQuickPick<IQuickPickItem & { service: boolean }>();
quickPick.placeholder = localize('tunnel.enable.placeholder', 'Select how you want to enable access');
quickPick.items = [
{ service: false, label: localize('tunnel.enable.session', 'Turn on for this session'), description: localize('tunnel.enable.session.description', 'Run whenever {0} is open', productService.nameShort) },
{ service: true, label: localize('tunnel.enable.service', 'Install as a service'), description: localize('tunnel.enable.service.description', 'Run whenever you\'re logged in') }
];
const asService = await new Promise<boolean | undefined>(resolve => {
disposables.add(quickPick.onDidAccept(() => resolve(quickPick.selectedItems[0]?.service)));
disposables.add(quickPick.onDidHide(() => resolve(undefined)));
quickPick.show();
});
quickPick.dispose();
if (asService === undefined) {
return; // no-op
}
const connectionInfo = await that.startTunnel(/* installAsService= */ asService);
if (connectionInfo) {
const linkToOpen = that.getLinkToOpen(connectionInfo);
const remoteExtension = that.serverConfiguration.extension;
const linkToOpenForMarkdown = linkToOpen.toString(false).replace(/\)/g, '%29');
await notificationService.notify({
notificationService.notify({
severity: Severity.Info,
message:
localize(
@@ -525,7 +567,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
const usedOnHostMessage: UsedOnHostMessage = { hostName: connectionInfo.tunnelName, timeStamp: new Date().getTime() };
storageService.store(REMOTE_TUNNEL_USED_STORAGE_KEY, JSON.stringify(usedOnHostMessage), StorageScope.APPLICATION, StorageTarget.USER);
} else {
await notificationService.notify({
notificationService.notify({
severity: Severity.Info,
message: localize('progress.turnOn.failed',
"Unable to turn on the remote tunnel access. Check the Remote Tunnel Service log for details."),
@@ -699,7 +741,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
private async showManageOptions() {
const account = await this.remoteTunnelService.getSession();
const account = await this.remoteTunnelService.getMode();
return new Promise<void>((c, e) => {
const disposables = new DisposableStore();
@@ -721,7 +763,7 @@ export class RemoteTunnelWorkbenchContribution extends Disposable implements IWo
items.push({ id: RemoteTunnelCommandIds.showLog, label: localize('manage.showLog', 'Show Log') });
items.push({ type: 'separator' });
items.push({ id: RemoteTunnelCommandIds.configure, label: localize('manage.tunnelName', 'Change Tunnel Name'), description: this.connectionInfo?.tunnelName });
items.push({ id: RemoteTunnelCommandIds.turnOff, label: RemoteTunnelCommandLabels.turnOff, description: account ? `${account.accountLabel} (${account.providerId})` : undefined });
items.push({ id: RemoteTunnelCommandIds.turnOff, label: RemoteTunnelCommandLabels.turnOff, description: account.active ? `${account.session.accountLabel} (${account.session.providerId})` : undefined });
quickPick.items = items;
disposables.add(quickPick.onDidAccept(() => {