Skip to content

Commit d91b601

Browse files
author
Miguel Targa
committed
Remote config
1 parent 2b5208e commit d91b601

File tree

8 files changed

+362
-4
lines changed

8 files changed

+362
-4
lines changed

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,30 @@ All notable changes to the "ssh-control" extension will be documented in this fi
44

55
Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file.
66

7+
## [0.2.0] - 2025-09-25
8+
9+
### Added
10+
- **Remote Hosts & Groups**: Support for fetching both hosts and groups from remote URLs
11+
- Fetch complete nested structures from HTTP/HTTPS endpoints
12+
- Support for JSON format with `hosts` and `groups` arrays
13+
- HTTP Basic Authentication support for secured endpoints
14+
- Automatic caching with 5-minute TTL to reduce network calls
15+
- Remote data merges with local data (additive, not replacement)
16+
- Error handling with fallback to cached data
17+
- **Timestamp Placeholder**: URLs now support `[timestamp]` placeholder for cache busting
18+
- `[timestamp]` gets replaced with current timestamp (milliseconds since epoch)
19+
- Useful for bypassing server-side caching: `?ts=[timestamp]`
20+
- **Add Child Groups**: UI button to create nested groups within existing groups
21+
- Folder icon (📁) button appears next to each group
22+
- Creates nested group structure with full inheritance support
23+
- **UI Commands**:
24+
- "Remote Cache Info" command to view cache status (shows hosts and groups count)
25+
26+
### Changed
27+
- **Enhanced Inheritance**: Remote groups support the same inheritance rules as local groups
28+
- Remote data is fetched asynchronously when groups are expanded
29+
- Regular refresh button now clears remote cache automatically
30+
731
## [0.1.0] - 2025-01-27
832

933
### Added

README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,69 @@ Creates `ssh-config.json` in your workspace or `~/.ssh-control/`:
6565
}
6666
```
6767

68+
### Remote Hosts
69+
70+
Groups can fetch server lists from remote URLs, enabling dynamic server discovery and centralized management.
71+
72+
```json
73+
{
74+
"name": "Database",
75+
"port": 3306,
76+
"remoteHosts": {
77+
"address": "https://api.example.com/servers.json?ts=[timestamp]",
78+
"basicAuth": {
79+
"username": "api_user",
80+
"password": "api_password"
81+
}
82+
},
83+
"hosts": [
84+
// Local hosts are merged with remote hosts/groups
85+
]
86+
}
87+
```
88+
89+
**Timestamp Placeholder:**
90+
The `[timestamp]` placeholder in URLs gets replaced with the current timestamp (milliseconds since epoch) when the request is made. This is useful for bypassing server-side caching:
91+
92+
```
93+
https://api.example.com/servers.json?ts=[timestamp]
94+
↓ becomes ↓
95+
https://api.example.com/servers.json?ts=1695691234567
96+
```
97+
98+
**Remote JSON Format:**
99+
```json
100+
{
101+
"hosts": [
102+
{
103+
"name": "server-01",
104+
"hostName": "10.0.1.10",
105+
"user": "admin"
106+
}
107+
],
108+
"groups": [
109+
{
110+
"name": "Nested Group",
111+
"defaultUser": "root",
112+
"hosts": [
113+
{
114+
"name": "nested-server",
115+
"hostName": "10.0.2.10"
116+
}
117+
]
118+
}
119+
]
120+
}
121+
```
122+
123+
**Remote Features:**
124+
- **Automatic Fetching**: Remote data is loaded when the group is expanded
125+
- **Caching**: Remote data is cached for 5 minutes to reduce network calls
126+
- **Full Structure Support**: Supports both hosts and nested groups in remote responses
127+
- **Format Support**: JSON structure or text format (hostname:name:user:port per line)
128+
- **Basic Authentication**: Optional HTTP basic auth support
129+
- **Timestamp Placeholder**: Use `[timestamp]` in URLs to bypass server-side caching
130+
- **Local Merge**: Remote hosts/groups are added to local ones, not replaced
131+
- **Error Handling**: Shows error messages if remote fetch fails, falls back to cache
132+
- **Manual Refresh**: Use "Refresh" button to clear cache and reload
133+

package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "ssh-control",
33
"displayName": "SSH Control",
44
"description": "Manage SSH connections directly from VSCode.",
5-
"version": "0.1.0",
5+
"version": "0.2.0",
66
"publisher": "migueltarga",
77
"author": {
88
"name": "Miguel Targa",
@@ -65,6 +65,11 @@
6565
"title": "Refresh",
6666
"icon": "$(refresh)"
6767
},
68+
{
69+
"command": "sshServers.cacheInfo",
70+
"title": "Remote Cache Info",
71+
"icon": "$(info)"
72+
},
6873
{
6974
"command": "sshServers.connect",
7075
"title": "Connect",

src/extension.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as vscode from 'vscode';
22
import { SSHConfigManager } from './sshConfigManager';
33
import { SSHTreeDataProvider } from './sshTreeDataProvider';
44
import { SSHConnectionManager } from './sshConnectionManager';
5+
import { RemoteHostsService } from './remoteHostsService';
56
import { SSHTreeItem } from './types';
67
import { showAddGroupDialog, showAddHostDialog, showEditHostDialog } from './dialogs';
78
import { getGroupChain } from './inheritance';
@@ -11,7 +12,8 @@ export function activate(context: vscode.ExtensionContext) {
1112

1213
// Initialize managers
1314
const configManager = new SSHConfigManager();
14-
const treeDataProvider = new SSHTreeDataProvider(configManager);
15+
const remoteHostsService = new RemoteHostsService();
16+
const treeDataProvider = new SSHTreeDataProvider(configManager, remoteHostsService);
1517
const connectionManager = new SSHConnectionManager();
1618

1719
// Register tree view
@@ -38,9 +40,22 @@ export function activate(context: vscode.ExtensionContext) {
3840

3941
// Register commands
4042
const refreshCommand = vscode.commands.registerCommand('sshServers.refresh', () => {
43+
remoteHostsService.clearCache();
4144
treeDataProvider.refresh();
4245
});
4346

47+
const cacheInfoCommand = vscode.commands.registerCommand('sshServers.cacheInfo', async () => {
48+
const cacheInfo = remoteHostsService.getCacheInfo();
49+
if (cacheInfo.length === 0) {
50+
vscode.window.showInformationMessage('No remote data cached.');
51+
} else {
52+
const infoMessage = cacheInfo.map(info =>
53+
`URL: ${info.url}\nHosts: ${info.hostsCount}\nGroups: ${info.groupsCount}\nAge: ${info.age}s`
54+
).join('\n\n');
55+
vscode.window.showInformationMessage(`Remote Data Cache:\n\n${infoMessage}`);
56+
}
57+
});
58+
4459
const connectCommand = vscode.commands.registerCommand('sshServers.connect', async (item: SSHTreeItem) => {
4560
if (item.type === 'host' && item.host && item.groupPath) {
4661
try {
@@ -164,6 +179,7 @@ export function activate(context: vscode.ExtensionContext) {
164179
context.subscriptions.push(
165180
treeView,
166181
refreshCommand,
182+
cacheInfoCommand,
167183
connectCommand,
168184
addGroupCommand,
169185
addChildGroupCommand,

src/remoteHostsService.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import * as vscode from 'vscode';
2+
import * as https from 'https';
3+
import * as http from 'http';
4+
import { URL } from 'url';
5+
import { SSHHost, SSHGroup, RemoteHostsConfig, RemoteResponse } from './types';
6+
7+
export class RemoteHostsService {
8+
private cache: Map<string, { response: RemoteResponse, timestamp: number }> = new Map();
9+
private readonly CACHE_DURATION = 5 * 60 * 1000; // 5 minutes cache
10+
11+
constructor() {}
12+
13+
private processUrl(url: string): string {
14+
return url.replace(/\[timestamp\]/g, Date.now().toString());
15+
}
16+
17+
async fetchRemoteData(config: RemoteHostsConfig): Promise<RemoteResponse> {
18+
const cacheKey = this.getCacheKey(config);
19+
const cached = this.cache.get(cacheKey);
20+
21+
if (cached && (Date.now() - cached.timestamp) < this.CACHE_DURATION) {
22+
return cached.response;
23+
}
24+
25+
try {
26+
const response = await this.downloadRemoteData(config);
27+
this.cache.set(cacheKey, { response, timestamp: Date.now() });
28+
return response;
29+
} catch (error) {
30+
console.error('Failed to fetch remote data:', error);
31+
32+
if (cached) {
33+
vscode.window.showWarningMessage(`Failed to fetch remote data, using cached data. Error: ${error}`);
34+
return cached.response;
35+
}
36+
37+
throw error;
38+
}
39+
}
40+
41+
private async downloadRemoteData(config: RemoteHostsConfig): Promise<RemoteResponse> {
42+
return new Promise((resolve, reject) => {
43+
const processedAddress = this.processUrl(config.address);
44+
const url = new URL(processedAddress);
45+
const isHttps = url.protocol === 'https:';
46+
const client = isHttps ? https : http;
47+
48+
const headers: { [key: string]: string } = {};
49+
50+
if (config.basicAuth) {
51+
const auth = Buffer.from(`${config.basicAuth.username}:${config.basicAuth.password}`).toString('base64');
52+
headers['Authorization'] = `Basic ${auth}`;
53+
}
54+
55+
const options: http.RequestOptions = {
56+
hostname: url.hostname,
57+
port: url.port || (isHttps ? 443 : 80),
58+
path: url.pathname + url.search,
59+
method: 'GET',
60+
headers
61+
};
62+
63+
const req = client.request(options, (res) => {
64+
let data = '';
65+
66+
res.on('data', (chunk) => {
67+
data += chunk;
68+
});
69+
70+
res.on('end', () => {
71+
try {
72+
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
73+
const response = this.parseRemoteData(data);
74+
resolve(response);
75+
} else {
76+
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
77+
}
78+
} catch (error) {
79+
reject(new Error(`Failed to parse remote data: ${error}`));
80+
}
81+
});
82+
});
83+
84+
req.on('error', (error) => {
85+
reject(error);
86+
});
87+
88+
req.setTimeout(10000, () => {
89+
req.destroy();
90+
reject(new Error('Request timeout'));
91+
});
92+
93+
req.end();
94+
});
95+
}
96+
97+
private parseRemoteData(data: string): RemoteResponse {
98+
try {
99+
const jsonData = JSON.parse(data);
100+
101+
if (Array.isArray(jsonData)) {
102+
return { hosts: jsonData };
103+
} else if (jsonData.hosts || jsonData.groups) {
104+
return {
105+
hosts: jsonData.hosts || [],
106+
groups: jsonData.groups || []
107+
};
108+
} else if (jsonData.hosts && Array.isArray(jsonData.hosts)) {
109+
return { hosts: jsonData.hosts };
110+
} else {
111+
throw new Error('Invalid JSON structure');
112+
}
113+
} catch (jsonError) {
114+
const hosts = this.parseTextFormat(data);
115+
return { hosts };
116+
}
117+
}
118+
119+
private parseTextFormat(data: string): SSHHost[] {
120+
const hosts: SSHHost[] = [];
121+
const lines = data.split('\n').filter(line => line.trim().length > 0);
122+
123+
for (const line of lines) {
124+
const trimmed = line.trim();
125+
126+
if (trimmed.startsWith('#') || trimmed.startsWith('//')) {
127+
continue;
128+
}
129+
130+
const parts = trimmed.split(':');
131+
132+
if (parts.length >= 1) {
133+
const host: SSHHost = {
134+
hostName: parts[0].trim(),
135+
name: parts[1]?.trim() || parts[0].trim()
136+
};
137+
138+
if (parts[2]?.trim()) {
139+
host.user = parts[2].trim();
140+
}
141+
142+
if (parts[3]?.trim()) {
143+
const port = parseInt(parts[3].trim(), 10);
144+
if (!isNaN(port)) {
145+
host.port = port;
146+
}
147+
}
148+
149+
hosts.push(host);
150+
}
151+
}
152+
153+
return hosts;
154+
}
155+
156+
private getCacheKey(config: RemoteHostsConfig): string {
157+
return `${config.address}:${config.basicAuth?.username || 'noauth'}`;
158+
}
159+
160+
clearCache(): void {
161+
this.cache.clear();
162+
}
163+
164+
getCacheInfo(): Array<{ url: string, hostsCount: number, groupsCount: number, age: number }> {
165+
const info: Array<{ url: string, hostsCount: number, groupsCount: number, age: number }> = [];
166+
const now = Date.now();
167+
168+
for (const [key, value] of this.cache.entries()) {
169+
const url = key.split(':')[0];
170+
info.push({
171+
url,
172+
hostsCount: value.response.hosts?.length || 0,
173+
groupsCount: value.response.groups?.length || 0,
174+
age: Math.floor((now - value.timestamp) / 1000)
175+
});
176+
}
177+
178+
return info;
179+
}
180+
}

0 commit comments

Comments
 (0)