Implement SSH port forwarding and improve jump host UI
Port Forwarding (new): - Add PortForward model with local/remote direction, bind/dest addresses - Implement ForwardManager in sshclient/portforward.go with full bidirectional tunnel support for both local and remote forwarding - Integrate ForwardManager into SSH client lifecycle (auto-start on connect, clean shutdown on close) - Add session manager methods: ListPortForwards, StartPortForward, StopPortForward with event emission - Add API surface: ListPortForwards, StartPortForward, StopPortForward - Add validation for port-forward rules in ValidateSSHConfig - Add port forwarding section to profile editor with direction, bind/dest host/port, label, and enable toggle - Add frontend event handling for forward:started/stopped/error - Style port-forward rows with charcoal card design matching jump hosts Jump Host UI improvements: - Add private key path field to jump host editor rows - Collect privateKeyPath in form value collection - Restyle jump host rows as two-line cards with clear field grouping
This commit is contained in:
@@ -486,12 +486,66 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.jump-host-row {
|
.jump-host-row {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1fr 80px 1fr auto;
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-2);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-host-fields {
|
||||||
|
display: flex;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------
|
||||||
|
Port-forwarding rows
|
||||||
|
----------------------------------------------------------------------- */
|
||||||
|
.port-forwards-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-forward-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding: var(--space-2);
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-forward-fields {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-forward-arrow {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-forward-enabled-label {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-1);
|
||||||
|
align-items: center;
|
||||||
|
font-size: var(--text-xs);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.port-forward-enabled-label input[type="checkbox"] {
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------
|
/* -----------------------------------------------------------------------
|
||||||
Payload preset rows
|
Payload preset rows
|
||||||
----------------------------------------------------------------------- */
|
----------------------------------------------------------------------- */
|
||||||
|
|||||||
@@ -180,6 +180,21 @@ class App {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Port-forwarding events
|
||||||
|
window.runtime.EventsOn('session:forward:started', (payload) => {
|
||||||
|
const fwd = payload.forward || {};
|
||||||
|
const label = fwd.label || `${fwd.bindHost || ''}:${fwd.bindPort} → ${fwd.destHost}:${fwd.destPort}`;
|
||||||
|
this.notifications.success(`Forward started: ${label}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.runtime.EventsOn('session:forward:stopped', (payload) => {
|
||||||
|
this.notifications.info(`Forward stopped: ${payload.forwardID}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.runtime.EventsOn('session:forward:error', (payload) => {
|
||||||
|
this.notifications.error(`Forward error: ${payload.error}`);
|
||||||
|
});
|
||||||
|
|
||||||
// Telnet negotiation debug messages
|
// Telnet negotiation debug messages
|
||||||
window.runtime.EventsOn('session:debug', (payload) => {
|
window.runtime.EventsOn('session:debug', (payload) => {
|
||||||
console.debug('[session:debug]', payload);
|
console.debug('[session:debug]', payload);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const DEFAULTS = {
|
|||||||
keepaliveCountMax: 3,
|
keepaliveCountMax: 3,
|
||||||
compression: false,
|
compression: false,
|
||||||
jumpHosts: [],
|
jumpHosts: [],
|
||||||
|
portForwards: [],
|
||||||
startupCommand: '',
|
startupCommand: '',
|
||||||
envVars: {},
|
envVars: {},
|
||||||
},
|
},
|
||||||
@@ -373,6 +374,11 @@ class ProfileEditor {
|
|||||||
this._renderJumpHostsList(ssh.jumpHosts || []),
|
this._renderJumpHostsList(ssh.jumpHosts || []),
|
||||||
], true));
|
], true));
|
||||||
|
|
||||||
|
// Port Forwarding
|
||||||
|
sections.push(this._renderSection('Port Forwarding', [
|
||||||
|
this._renderPortForwardsList(ssh.portForwards || []),
|
||||||
|
], true));
|
||||||
|
|
||||||
// Startup
|
// Startup
|
||||||
sections.push(this._renderSection('Startup', [
|
sections.push(this._renderSection('Startup', [
|
||||||
this._field('text', 'ssh.startupCommand', 'Startup Command', ssh.startupCommand, {
|
this._field('text', 'ssh.startupCommand', 'Startup Command', ssh.startupCommand, {
|
||||||
@@ -712,10 +718,15 @@ class ProfileEditor {
|
|||||||
const jh = jumpHosts[i];
|
const jh = jumpHosts[i];
|
||||||
const row = createElement('div', 'jump-host-row');
|
const row = createElement('div', 'jump-host-row');
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<input class="form-input form-input-sm" name="jumpHost-${i}-host" value="${escapeHtml(jh.host || '')}" placeholder="Host" />
|
<div class="jump-host-fields">
|
||||||
<input class="form-input form-input-sm" name="jumpHost-${i}-port" type="number" value="${jh.port || 22}" min="1" max="65535" style="width:80px" />
|
<input class="form-input form-input-sm" name="jumpHost-${i}-host" value="${escapeHtml(jh.host || '')}" placeholder="Host" />
|
||||||
<input class="form-input form-input-sm" name="jumpHost-${i}-username" value="${escapeHtml(jh.username || '')}" placeholder="User" />
|
<input class="form-input form-input-sm" name="jumpHost-${i}-port" type="number" value="${jh.port || 22}" min="1" max="65535" style="width:80px" />
|
||||||
<button class="btn btn-icon btn-danger jump-host-remove" data-index="${i}" title="Remove">×</button>
|
<input class="form-input form-input-sm" name="jumpHost-${i}-username" value="${escapeHtml(jh.username || '')}" placeholder="User" />
|
||||||
|
</div>
|
||||||
|
<div class="jump-host-fields">
|
||||||
|
<input class="form-input form-input-sm" name="jumpHost-${i}-privateKeyPath" value="${escapeHtml(jh.privateKeyPath || '')}" placeholder="Private key path (optional)" style="flex:1" />
|
||||||
|
<button class="btn btn-icon btn-danger jump-host-remove" data-index="${i}" title="Remove">×</button>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
row.querySelector('.jump-host-remove').addEventListener('click', () => {
|
row.querySelector('.jump-host-remove').addEventListener('click', () => {
|
||||||
jumpHosts.splice(i, 1);
|
jumpHosts.splice(i, 1);
|
||||||
@@ -734,6 +745,65 @@ class ProfileEditor {
|
|||||||
return wrapper;
|
return wrapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the port-forwarding rules list editor.
|
||||||
|
* @param {Array<Object>} portForwards
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
* @private
|
||||||
|
*/
|
||||||
|
_renderPortForwardsList(portForwards) {
|
||||||
|
const wrapper = createElement('div', 'port-forwards-list');
|
||||||
|
|
||||||
|
for (let i = 0; i < portForwards.length; i++) {
|
||||||
|
const pf = portForwards[i];
|
||||||
|
const row = createElement('div', 'port-forward-row');
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="port-forward-fields">
|
||||||
|
<select class="form-select form-select-sm" name="portFwd-${i}-direction" style="width:110px">
|
||||||
|
<option value="local" ${pf.direction === 'local' ? 'selected' : ''}>Local →</option>
|
||||||
|
<option value="remote" ${pf.direction === 'remote' ? 'selected' : ''}>← Remote</option>
|
||||||
|
</select>
|
||||||
|
<input class="form-input form-input-sm" name="portFwd-${i}-bindHost" value="${escapeHtml(pf.bindHost || '127.0.0.1')}" placeholder="Bind host" style="width:110px" />
|
||||||
|
<input class="form-input form-input-sm" name="portFwd-${i}-bindPort" type="number" value="${pf.bindPort || 0}" min="1" max="65535" placeholder="Bind port" style="width:80px" />
|
||||||
|
<span class="port-forward-arrow">⟶</span>
|
||||||
|
<input class="form-input form-input-sm" name="portFwd-${i}-destHost" value="${escapeHtml(pf.destHost || '')}" placeholder="Dest host" style="width:110px" />
|
||||||
|
<input class="form-input form-input-sm" name="portFwd-${i}-destPort" type="number" value="${pf.destPort || 0}" min="1" max="65535" placeholder="Dest port" style="width:80px" />
|
||||||
|
</div>
|
||||||
|
<div class="port-forward-fields">
|
||||||
|
<input class="form-input form-input-sm" name="portFwd-${i}-label" value="${escapeHtml(pf.label || '')}" placeholder="Label (optional)" style="flex:1" />
|
||||||
|
<label class="port-forward-enabled-label" title="Enable on connect">
|
||||||
|
<input type="checkbox" name="portFwd-${i}-enabled" ${pf.enabled !== false ? 'checked' : ''} />
|
||||||
|
<span>Active</span>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-icon btn-danger port-forward-remove" data-index="${i}" title="Remove">×</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
row.querySelector('.port-forward-remove').addEventListener('click', () => {
|
||||||
|
portForwards.splice(i, 1);
|
||||||
|
this.render(this._profile, this._isNew);
|
||||||
|
});
|
||||||
|
wrapper.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addBtn = createElement('button', 'btn btn-sm btn-outline', { textContent: '+ Add Port Forward' });
|
||||||
|
addBtn.addEventListener('click', () => {
|
||||||
|
portForwards.push({
|
||||||
|
id: generateId(),
|
||||||
|
direction: 'local',
|
||||||
|
bindHost: '127.0.0.1',
|
||||||
|
bindPort: 0,
|
||||||
|
destHost: '',
|
||||||
|
destPort: 0,
|
||||||
|
enabled: true,
|
||||||
|
label: '',
|
||||||
|
});
|
||||||
|
this.render(this._profile, this._isNew);
|
||||||
|
});
|
||||||
|
wrapper.appendChild(addBtn);
|
||||||
|
|
||||||
|
return wrapper;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders the payload-presets list editor.
|
* Renders the payload-presets list editor.
|
||||||
* @param {Array<Object>} presets
|
* @param {Array<Object>} presets
|
||||||
@@ -855,11 +925,29 @@ class ProfileEditor {
|
|||||||
username: val(`jumpHost-${ji}-username`) || '',
|
username: val(`jumpHost-${ji}-username`) || '',
|
||||||
authMethod: 'publickey',
|
authMethod: 'publickey',
|
||||||
secretMode: 'ask-every-time',
|
secretMode: 'ask-every-time',
|
||||||
privateKeyPath: '',
|
privateKeyPath: val(`jumpHost-${ji}-privateKeyPath`) || '',
|
||||||
});
|
});
|
||||||
ji++;
|
ji++;
|
||||||
}
|
}
|
||||||
p.ssh.jumpHosts = jh;
|
p.ssh.jumpHosts = jh;
|
||||||
|
|
||||||
|
// Port forwards
|
||||||
|
const pfs = [];
|
||||||
|
let fi = 0;
|
||||||
|
while (this.drawer.querySelector(`[name="portFwd-${fi}-direction"]`)) {
|
||||||
|
pfs.push({
|
||||||
|
id: (p.ssh.portForwards && p.ssh.portForwards[fi]?.id) || generateId(),
|
||||||
|
direction: val(`portFwd-${fi}-direction`) || 'local',
|
||||||
|
bindHost: val(`portFwd-${fi}-bindHost`) || '127.0.0.1',
|
||||||
|
bindPort: val(`portFwd-${fi}-bindPort`) || 0,
|
||||||
|
destHost: val(`portFwd-${fi}-destHost`) || '',
|
||||||
|
destPort: val(`portFwd-${fi}-destPort`) || 0,
|
||||||
|
enabled: val(`portFwd-${fi}-enabled`) !== false,
|
||||||
|
label: val(`portFwd-${fi}-label`) || '',
|
||||||
|
});
|
||||||
|
fi++;
|
||||||
|
}
|
||||||
|
p.ssh.portForwards = pfs;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Telnet-specific
|
// Telnet-specific
|
||||||
|
|||||||
Vendored
+6
@@ -47,6 +47,8 @@ export function ListFavourites():Promise<Array<models.Profile>>;
|
|||||||
|
|
||||||
export function ListKnownHosts():Promise<Array<models.KnownHost>>;
|
export function ListKnownHosts():Promise<Array<models.KnownHost>>;
|
||||||
|
|
||||||
|
export function ListPortForwards(arg1:string):Promise<Array<models.PortForward>>;
|
||||||
|
|
||||||
export function ListProfiles():Promise<Array<models.Profile>>;
|
export function ListProfiles():Promise<Array<models.Profile>>;
|
||||||
|
|
||||||
export function ListProfilesByFolder(arg1:string):Promise<Array<models.Profile>>;
|
export function ListProfilesByFolder(arg1:string):Promise<Array<models.Profile>>;
|
||||||
@@ -91,6 +93,10 @@ export function SetFavourite(arg1:string,arg2:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function SetProfileSecret(arg1:string,arg2:string,arg3:string):Promise<void>;
|
export function SetProfileSecret(arg1:string,arg2:string,arg3:string):Promise<void>;
|
||||||
|
|
||||||
|
export function StartPortForward(arg1:string,arg2:models.PortForward):Promise<void>;
|
||||||
|
|
||||||
|
export function StopPortForward(arg1:string,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function TestConnection(arg1:string,arg2:number):Promise<Record<string, any>>;
|
export function TestConnection(arg1:string,arg2:number):Promise<Record<string, any>>;
|
||||||
|
|
||||||
export function UnlockSecretStore(arg1:string):Promise<void>;
|
export function UnlockSecretStore(arg1:string):Promise<void>;
|
||||||
|
|||||||
@@ -90,6 +90,10 @@ export function ListKnownHosts() {
|
|||||||
return window['go']['api']['Service']['ListKnownHosts']();
|
return window['go']['api']['Service']['ListKnownHosts']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ListPortForwards(arg1) {
|
||||||
|
return window['go']['api']['Service']['ListPortForwards'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ListProfiles() {
|
export function ListProfiles() {
|
||||||
return window['go']['api']['Service']['ListProfiles']();
|
return window['go']['api']['Service']['ListProfiles']();
|
||||||
}
|
}
|
||||||
@@ -178,6 +182,14 @@ export function SetProfileSecret(arg1, arg2, arg3) {
|
|||||||
return window['go']['api']['Service']['SetProfileSecret'](arg1, arg2, arg3);
|
return window['go']['api']['Service']['SetProfileSecret'](arg1, arg2, arg3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function StartPortForward(arg1, arg2) {
|
||||||
|
return window['go']['api']['Service']['StartPortForward'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StopPortForward(arg1, arg2) {
|
||||||
|
return window['go']['api']['Service']['StopPortForward'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
export function TestConnection(arg1, arg2) {
|
export function TestConnection(arg1, arg2) {
|
||||||
return window['go']['api']['Service']['TestConnection'](arg1, arg2);
|
return window['go']['api']['Service']['TestConnection'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,6 +209,32 @@ export namespace models {
|
|||||||
this.format = source["format"];
|
this.format = source["format"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class PortForward {
|
||||||
|
id: string;
|
||||||
|
direction: string;
|
||||||
|
bindHost: string;
|
||||||
|
bindPort: number;
|
||||||
|
destHost: string;
|
||||||
|
destPort: number;
|
||||||
|
enabled: boolean;
|
||||||
|
label: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new PortForward(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.direction = source["direction"];
|
||||||
|
this.bindHost = source["bindHost"];
|
||||||
|
this.bindPort = source["bindPort"];
|
||||||
|
this.destHost = source["destHost"];
|
||||||
|
this.destPort = source["destPort"];
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.label = source["label"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class RAWConfig {
|
export class RAWConfig {
|
||||||
autoReconnect: boolean;
|
autoReconnect: boolean;
|
||||||
lineEnding: string;
|
lineEnding: string;
|
||||||
@@ -282,6 +308,7 @@ export namespace models {
|
|||||||
keepaliveCountMax: number;
|
keepaliveCountMax: number;
|
||||||
compression: boolean;
|
compression: boolean;
|
||||||
jumpHosts: JumpHost[];
|
jumpHosts: JumpHost[];
|
||||||
|
portForwards: PortForward[];
|
||||||
startupCommand: string;
|
startupCommand: string;
|
||||||
envVars: Record<string, string>;
|
envVars: Record<string, string>;
|
||||||
|
|
||||||
@@ -301,6 +328,7 @@ export namespace models {
|
|||||||
this.keepaliveCountMax = source["keepaliveCountMax"];
|
this.keepaliveCountMax = source["keepaliveCountMax"];
|
||||||
this.compression = source["compression"];
|
this.compression = source["compression"];
|
||||||
this.jumpHosts = this.convertValues(source["jumpHosts"], JumpHost);
|
this.jumpHosts = this.convertValues(source["jumpHosts"], JumpHost);
|
||||||
|
this.portForwards = this.convertValues(source["portForwards"], PortForward);
|
||||||
this.startupCommand = source["startupCommand"];
|
this.startupCommand = source["startupCommand"];
|
||||||
this.envVars = source["envVars"];
|
this.envVars = source["envVars"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -624,6 +624,25 @@ func (s *Service) RefreshGitHubRepos(sessionID string) ([]models.GitHubRepo, err
|
|||||||
return s.sessions.FetchGitHubRepos(sessionID)
|
return s.sessions.FetchGitHubRepos(sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Port forwarding
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ListPortForwards returns the active port-forwarding tunnels for an SSH session.
|
||||||
|
func (s *Service) ListPortForwards(sessionID string) ([]models.PortForward, error) {
|
||||||
|
return s.sessions.ListPortForwards(sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartPortForward activates a new forwarding tunnel on a live SSH session.
|
||||||
|
func (s *Service) StartPortForward(sessionID string, rule models.PortForward) error {
|
||||||
|
return s.sessions.StartPortForward(sessionID, rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopPortForward terminates an active forwarding tunnel.
|
||||||
|
func (s *Service) StopPortForward(sessionID string, forwardID string) error {
|
||||||
|
return s.sessions.StopPortForward(sessionID, forwardID)
|
||||||
|
}
|
||||||
|
|
||||||
// TestConnection performs a basic TCP reachability check to the given host and port.
|
// TestConnection performs a basic TCP reachability check to the given host and port.
|
||||||
// This verifies the network path without establishing a full protocol session.
|
// This verifies the network path without establishing a full protocol session.
|
||||||
func (s *Service) TestConnection(host string, port int) (map[string]interface{}, error) {
|
func (s *Service) TestConnection(host string, port int) (map[string]interface{}, error) {
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ func NewProfile(protocol Protocol) Profile {
|
|||||||
KeepaliveInterval: DefaultKeepaliveInterval,
|
KeepaliveInterval: DefaultKeepaliveInterval,
|
||||||
KeepaliveCountMax: DefaultKeepaliveCountMax,
|
KeepaliveCountMax: DefaultKeepaliveCountMax,
|
||||||
JumpHosts: []JumpHost{},
|
JumpHosts: []JumpHost{},
|
||||||
|
PortForwards: []PortForward{},
|
||||||
EnvVars: map[string]string{},
|
EnvVars: map[string]string{},
|
||||||
}
|
}
|
||||||
case ProtocolTelnet:
|
case ProtocolTelnet:
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ type SSHConfig struct {
|
|||||||
KeepaliveCountMax int `json:"keepaliveCountMax"`
|
KeepaliveCountMax int `json:"keepaliveCountMax"`
|
||||||
Compression bool `json:"compression"`
|
Compression bool `json:"compression"`
|
||||||
JumpHosts []JumpHost `json:"jumpHosts"`
|
JumpHosts []JumpHost `json:"jumpHosts"`
|
||||||
|
PortForwards []PortForward `json:"portForwards"`
|
||||||
StartupCommand string `json:"startupCommand"`
|
StartupCommand string `json:"startupCommand"`
|
||||||
EnvVars map[string]string `json:"envVars"`
|
EnvVars map[string]string `json:"envVars"`
|
||||||
}
|
}
|
||||||
@@ -108,6 +109,26 @@ type JumpHost struct {
|
|||||||
PrivateKeyPath string `json:"privateKeyPath"`
|
PrivateKeyPath string `json:"privateKeyPath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PortForwardDirection indicates the tunnel direction.
|
||||||
|
type PortForwardDirection string
|
||||||
|
|
||||||
|
const (
|
||||||
|
PortForwardLocal PortForwardDirection = "local"
|
||||||
|
PortForwardRemote PortForwardDirection = "remote"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PortForward defines a single SSH port-forwarding rule.
|
||||||
|
type PortForward struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Direction PortForwardDirection `json:"direction"`
|
||||||
|
BindHost string `json:"bindHost"` // local address to listen on (local) or remote bind (remote)
|
||||||
|
BindPort int `json:"bindPort"`
|
||||||
|
DestHost string `json:"destHost"` // target address to forward to
|
||||||
|
DestPort int `json:"destPort"`
|
||||||
|
Enabled bool `json:"enabled"` // whether to activate on connect
|
||||||
|
Label string `json:"label"` // optional descriptive label
|
||||||
|
}
|
||||||
|
|
||||||
// TelnetConfig holds Telnet-specific settings for a connection profile.
|
// TelnetConfig holds Telnet-specific settings for a connection profile.
|
||||||
type TelnetConfig struct {
|
type TelnetConfig struct {
|
||||||
LocalEcho bool `json:"localEcho"`
|
LocalEcho bool `json:"localEcho"`
|
||||||
|
|||||||
@@ -470,6 +470,33 @@ func (m *Manager) connectSSH(sess *Session, getSecret func(prompt string) (strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
sess.sshClient = client
|
sess.sshClient = client
|
||||||
|
|
||||||
|
// Start any port-forwarding rules configured in the profile.
|
||||||
|
if sshConf != nil && len(sshConf.PortForwards) > 0 {
|
||||||
|
fwdMgr := client.Forwards()
|
||||||
|
if fwdMgr != nil {
|
||||||
|
for _, pf := range sshConf.PortForwards {
|
||||||
|
if !pf.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := fwdMgr.StartForward(pf); err != nil {
|
||||||
|
// Non-fatal — log and notify but don't abort the session.
|
||||||
|
m.emitEvent("session:forward:error", map[string]interface{}{
|
||||||
|
"sessionID": sess.Info.ID,
|
||||||
|
"forwardID": pf.ID,
|
||||||
|
"label": pf.Label,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
m.emitEvent("session:forward:started", map[string]interface{}{
|
||||||
|
"sessionID": sess.Info.ID,
|
||||||
|
"forward": pf,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -668,6 +695,88 @@ func (m *Manager) FetchGitHubRepos(sessionID string) ([]models.GitHubRepo, error
|
|||||||
return repos, nil
|
return repos, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Port-forwarding management
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ListPortForwards returns the active port-forwarding rules for a session.
|
||||||
|
func (m *Manager) ListPortForwards(sessionID string) ([]models.PortForward, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
sess, ok := m.sessions[sessionID]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("sessions: session %q not found", sessionID)
|
||||||
|
}
|
||||||
|
if sess.sshClient == nil {
|
||||||
|
return nil, fmt.Errorf("sessions: session %q is not an SSH connection", sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
fwdMgr := sess.sshClient.Forwards()
|
||||||
|
if fwdMgr == nil {
|
||||||
|
return []models.PortForward{}, nil
|
||||||
|
}
|
||||||
|
return fwdMgr.ActiveForwards(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartPortForward activates a new forwarding tunnel on a live SSH session.
|
||||||
|
func (m *Manager) StartPortForward(sessionID string, rule models.PortForward) error {
|
||||||
|
m.mu.RLock()
|
||||||
|
sess, ok := m.sessions[sessionID]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("sessions: session %q not found", sessionID)
|
||||||
|
}
|
||||||
|
if sess.sshClient == nil {
|
||||||
|
return fmt.Errorf("sessions: session %q is not an SSH connection", sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
fwdMgr := sess.sshClient.Forwards()
|
||||||
|
if fwdMgr == nil {
|
||||||
|
return fmt.Errorf("sessions: forward manager not initialised")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fwdMgr.StartForward(rule); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.emitEvent("session:forward:started", map[string]interface{}{
|
||||||
|
"sessionID": sessionID,
|
||||||
|
"forward": rule,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopPortForward terminates an active forwarding tunnel.
|
||||||
|
func (m *Manager) StopPortForward(sessionID string, forwardID string) error {
|
||||||
|
m.mu.RLock()
|
||||||
|
sess, ok := m.sessions[sessionID]
|
||||||
|
m.mu.RUnlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("sessions: session %q not found", sessionID)
|
||||||
|
}
|
||||||
|
if sess.sshClient == nil {
|
||||||
|
return fmt.Errorf("sessions: session %q is not an SSH connection", sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
fwdMgr := sess.sshClient.Forwards()
|
||||||
|
if fwdMgr == nil {
|
||||||
|
return fmt.Errorf("sessions: forward manager not initialised")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := fwdMgr.StopForward(forwardID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.emitEvent("session:forward:stopped", map[string]interface{}{
|
||||||
|
"sessionID": sessionID,
|
||||||
|
"forwardID": forwardID,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Internal: helpers
|
// Internal: helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
// Package sshclient — port forwarding support.
|
||||||
|
//
|
||||||
|
// This file implements local and remote SSH port forwarding (tunnelling).
|
||||||
|
// Local forwarding listens on a local address and pipes connections through
|
||||||
|
// the SSH channel to a remote destination. Remote forwarding requests the
|
||||||
|
// SSH server to listen on a remote address and pipes accepted connections
|
||||||
|
// back to a local destination.
|
||||||
|
package sshclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/copilot-ssh-client/app/internal/models"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ActiveForward tracks a running port-forwarding tunnel.
|
||||||
|
type ActiveForward struct {
|
||||||
|
Rule models.PortForward `json:"rule"`
|
||||||
|
Listener net.Listener `json:"-"`
|
||||||
|
done chan struct{}
|
||||||
|
mu sync.Mutex
|
||||||
|
conns []net.Conn // tracked connections for clean shutdown
|
||||||
|
}
|
||||||
|
|
||||||
|
// ForwardManager manages active port-forwarding tunnels for a single SSH connection.
|
||||||
|
type ForwardManager struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
forwards map[string]*ActiveForward
|
||||||
|
client *ssh.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewForwardManager creates a ForwardManager bound to the given SSH client.
|
||||||
|
func NewForwardManager(client *ssh.Client) *ForwardManager {
|
||||||
|
return &ForwardManager{
|
||||||
|
forwards: make(map[string]*ActiveForward),
|
||||||
|
client: client,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartForward activates a port-forwarding rule. Returns an error if the
|
||||||
|
// rule's ID is already active or the listener cannot be established.
|
||||||
|
func (fm *ForwardManager) StartForward(rule models.PortForward) error {
|
||||||
|
fm.mu.Lock()
|
||||||
|
if _, exists := fm.forwards[rule.ID]; exists {
|
||||||
|
fm.mu.Unlock()
|
||||||
|
return fmt.Errorf("forward %q is already active", rule.ID)
|
||||||
|
}
|
||||||
|
fm.mu.Unlock()
|
||||||
|
|
||||||
|
switch rule.Direction {
|
||||||
|
case models.PortForwardLocal:
|
||||||
|
return fm.startLocalForward(rule)
|
||||||
|
case models.PortForwardRemote:
|
||||||
|
return fm.startRemoteForward(rule)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported forward direction %q", rule.Direction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopForward terminates a specific active forward.
|
||||||
|
func (fm *ForwardManager) StopForward(forwardID string) error {
|
||||||
|
fm.mu.Lock()
|
||||||
|
af, ok := fm.forwards[forwardID]
|
||||||
|
if !ok {
|
||||||
|
fm.mu.Unlock()
|
||||||
|
return fmt.Errorf("forward %q not found", forwardID)
|
||||||
|
}
|
||||||
|
delete(fm.forwards, forwardID)
|
||||||
|
fm.mu.Unlock()
|
||||||
|
|
||||||
|
return af.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopAll terminates every active forward. Called on session close.
|
||||||
|
func (fm *ForwardManager) StopAll() {
|
||||||
|
fm.mu.Lock()
|
||||||
|
all := make([]*ActiveForward, 0, len(fm.forwards))
|
||||||
|
for id, af := range fm.forwards {
|
||||||
|
all = append(all, af)
|
||||||
|
delete(fm.forwards, id)
|
||||||
|
}
|
||||||
|
fm.mu.Unlock()
|
||||||
|
|
||||||
|
for _, af := range all {
|
||||||
|
_ = af.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActiveForwards returns the list of currently active forwarding rules.
|
||||||
|
func (fm *ForwardManager) ActiveForwards() []models.PortForward {
|
||||||
|
fm.mu.Lock()
|
||||||
|
defer fm.mu.Unlock()
|
||||||
|
|
||||||
|
result := make([]models.PortForward, 0, len(fm.forwards))
|
||||||
|
for _, af := range fm.forwards {
|
||||||
|
result = append(result, af.Rule)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Local forwarding: listen locally → tunnel to remote via SSH
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (fm *ForwardManager) startLocalForward(rule models.PortForward) error {
|
||||||
|
bindAddr := rule.BindHost
|
||||||
|
if bindAddr == "" {
|
||||||
|
bindAddr = "127.0.0.1"
|
||||||
|
}
|
||||||
|
listenAddr := net.JoinHostPort(bindAddr, fmt.Sprintf("%d", rule.BindPort))
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", listenAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("local forward: listen on %s failed: %w", listenAddr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
af := &ActiveForward{
|
||||||
|
Rule: rule,
|
||||||
|
Listener: listener,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
fm.mu.Lock()
|
||||||
|
fm.forwards[rule.ID] = af
|
||||||
|
fm.mu.Unlock()
|
||||||
|
|
||||||
|
destAddr := net.JoinHostPort(rule.DestHost, fmt.Sprintf("%d", rule.DestPort))
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(af.done)
|
||||||
|
for {
|
||||||
|
localConn, acceptErr := listener.Accept()
|
||||||
|
if acceptErr != nil {
|
||||||
|
// Listener closed — normal shutdown path.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
af.mu.Lock()
|
||||||
|
af.conns = append(af.conns, localConn)
|
||||||
|
af.mu.Unlock()
|
||||||
|
|
||||||
|
go func(lc net.Conn) {
|
||||||
|
defer lc.Close()
|
||||||
|
|
||||||
|
remoteConn, dialErr := fm.client.Dial("tcp", destAddr)
|
||||||
|
if dialErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer remoteConn.Close()
|
||||||
|
|
||||||
|
af.mu.Lock()
|
||||||
|
af.conns = append(af.conns, remoteConn)
|
||||||
|
af.mu.Unlock()
|
||||||
|
|
||||||
|
pipeBidirectional(lc, remoteConn)
|
||||||
|
}(localConn)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Remote forwarding: ask server to listen → tunnel back to local destination
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (fm *ForwardManager) startRemoteForward(rule models.PortForward) error {
|
||||||
|
bindAddr := rule.BindHost
|
||||||
|
if bindAddr == "" {
|
||||||
|
bindAddr = "0.0.0.0"
|
||||||
|
}
|
||||||
|
listenAddr := net.JoinHostPort(bindAddr, fmt.Sprintf("%d", rule.BindPort))
|
||||||
|
|
||||||
|
listener, err := fm.client.Listen("tcp", listenAddr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("remote forward: server listen on %s failed: %w", listenAddr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
af := &ActiveForward{
|
||||||
|
Rule: rule,
|
||||||
|
Listener: listener,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
fm.mu.Lock()
|
||||||
|
fm.forwards[rule.ID] = af
|
||||||
|
fm.mu.Unlock()
|
||||||
|
|
||||||
|
destAddr := net.JoinHostPort(rule.DestHost, fmt.Sprintf("%d", rule.DestPort))
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer close(af.done)
|
||||||
|
for {
|
||||||
|
remoteConn, acceptErr := listener.Accept()
|
||||||
|
if acceptErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
af.mu.Lock()
|
||||||
|
af.conns = append(af.conns, remoteConn)
|
||||||
|
af.mu.Unlock()
|
||||||
|
|
||||||
|
go func(rc net.Conn) {
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
localConn, dialErr := net.Dial("tcp", destAddr)
|
||||||
|
if dialErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer localConn.Close()
|
||||||
|
|
||||||
|
af.mu.Lock()
|
||||||
|
af.conns = append(af.conns, localConn)
|
||||||
|
af.mu.Unlock()
|
||||||
|
|
||||||
|
pipeBidirectional(rc, localConn)
|
||||||
|
}(remoteConn)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// shutdown closes the listener and all tracked connections.
|
||||||
|
func (af *ActiveForward) shutdown() error {
|
||||||
|
var firstErr error
|
||||||
|
if af.Listener != nil {
|
||||||
|
if err := af.Listener.Close(); err != nil {
|
||||||
|
firstErr = err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
af.mu.Lock()
|
||||||
|
conns := af.conns
|
||||||
|
af.conns = nil
|
||||||
|
af.mu.Unlock()
|
||||||
|
|
||||||
|
for _, c := range conns {
|
||||||
|
_ = c.Close()
|
||||||
|
}
|
||||||
|
return firstErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// pipeBidirectional copies data between two connections until either side closes.
|
||||||
|
func pipeBidirectional(a, b net.Conn) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
copy := func(dst, src net.Conn) {
|
||||||
|
defer wg.Done()
|
||||||
|
_, _ = io.Copy(dst, src)
|
||||||
|
// Signal the other direction to stop by closing the write-half.
|
||||||
|
if tc, ok := dst.(*net.TCPConn); ok {
|
||||||
|
_ = tc.CloseWrite()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go copy(a, b)
|
||||||
|
go copy(b, a)
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
|
|
||||||
// Client manages a single SSH session lifecycle.
|
// Client manages a single SSH session lifecycle.
|
||||||
// It handles connection establishment, authentication, PTY allocation,
|
// It handles connection establishment, authentication, PTY allocation,
|
||||||
// terminal resize propagation, and clean shutdown.
|
// terminal resize propagation, port forwarding, and clean shutdown.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
config *models.Profile
|
config *models.Profile
|
||||||
@@ -30,6 +30,7 @@ type Client struct {
|
|||||||
onClose func()
|
onClose func()
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
forwards *ForwardManager // port forwarding tunnels
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a new SSH client bound to the given profile.
|
// NewClient creates a new SSH client bound to the given profile.
|
||||||
@@ -193,6 +194,7 @@ func (c *Client) connectInternal(
|
|||||||
c.stdout = stdoutPipe
|
c.stdout = stdoutPipe
|
||||||
c.stderr = stderrPipe
|
c.stderr = stderrPipe
|
||||||
c.connected = true
|
c.connected = true
|
||||||
|
c.forwards = NewForwardManager(sshConn)
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
go c.readLoop()
|
go c.readLoop()
|
||||||
@@ -242,11 +244,17 @@ func (c *Client) Close() error {
|
|||||||
c.connected = false
|
c.connected = false
|
||||||
session := c.session
|
session := c.session
|
||||||
sshClient := c.sshClient
|
sshClient := c.sshClient
|
||||||
|
fwdMgr := c.forwards
|
||||||
onClose := c.onClose
|
onClose := c.onClose
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
c.cancel()
|
c.cancel()
|
||||||
|
|
||||||
|
// Shut down any active port forwards first.
|
||||||
|
if fwdMgr != nil {
|
||||||
|
fwdMgr.StopAll()
|
||||||
|
}
|
||||||
|
|
||||||
var firstErr error
|
var firstErr error
|
||||||
if session != nil {
|
if session != nil {
|
||||||
if err := session.Close(); err != nil && firstErr == nil {
|
if err := session.Close(); err != nil && firstErr == nil {
|
||||||
@@ -272,6 +280,13 @@ func (c *Client) IsConnected() bool {
|
|||||||
return c.connected
|
return c.connected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Forwards returns the ForwardManager for this client, or nil if not connected.
|
||||||
|
func (c *Client) Forwards() *ForwardManager {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.forwards
|
||||||
|
}
|
||||||
|
|
||||||
// buildAuthMethods constructs the SSH authentication method list from the
|
// buildAuthMethods constructs the SSH authentication method list from the
|
||||||
// profile's SSHConfig and the provided credential callbacks.
|
// profile's SSHConfig and the provided credential callbacks.
|
||||||
func (c *Client) buildAuthMethods(sc models.SSHConfig, getPassword func() (string, error), getPassphrase func() (string, error)) ([]ssh.AuthMethod, error) {
|
func (c *Client) buildAuthMethods(sc models.SSHConfig, getPassword func() (string, error), getPassphrase func() (string, error)) ([]ssh.AuthMethod, error) {
|
||||||
|
|||||||
@@ -93,6 +93,8 @@ func ValidateProfile(p *models.Profile) error {
|
|||||||
errs.Errors = append(errs.Errors, ve.Errors...)
|
errs.Errors = append(errs.Errors, ve.Errors...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case models.ProtocolGitHub:
|
||||||
|
// GitHub profiles require no host-level validation beyond the common checks.
|
||||||
default:
|
default:
|
||||||
errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol))
|
errs.Add("protocol", fmt.Sprintf("unsupported protocol %q", p.Protocol))
|
||||||
}
|
}
|
||||||
@@ -217,6 +219,23 @@ func ValidateSSHConfig(cfg *models.SSHConfig) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate each port-forwarding rule.
|
||||||
|
for i, pf := range cfg.PortForwards {
|
||||||
|
prefix := fmt.Sprintf("ssh.portForwards[%d]", i)
|
||||||
|
if pf.Direction != models.PortForwardLocal && pf.Direction != models.PortForwardRemote {
|
||||||
|
errs.Add(prefix+".direction", fmt.Sprintf("unsupported forwarding direction %q", pf.Direction))
|
||||||
|
}
|
||||||
|
if pf.BindPort < 1 || pf.BindPort > 65535 {
|
||||||
|
errs.Add(prefix+".bindPort", "bind port must be between 1 and 65535")
|
||||||
|
}
|
||||||
|
if pf.DestPort < 1 || pf.DestPort > 65535 {
|
||||||
|
errs.Add(prefix+".destPort", "destination port must be between 1 and 65535")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(pf.DestHost) == "" {
|
||||||
|
errs.Add(prefix+".destHost", "destination host must not be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if errs.HasErrors() {
|
if errs.HasErrors() {
|
||||||
return errs
|
return errs
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user