411 lines
15 KiB
TypeScript
411 lines
15 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import type { SshConnection } from '../../lib/ssh-types';
|
|
|
|
interface SshConnectionFormProps {
|
|
/** Existing connection for edit; null for create. */
|
|
existing: SshConnection | null;
|
|
/** True when rendered in admin context — exposes admin-only flags. */
|
|
adminContext: boolean;
|
|
/** Submit handler; receives the request body and returns a Promise. */
|
|
onSubmit: (body: Record<string, unknown>) => Promise<void>;
|
|
onCancel: () => void;
|
|
}
|
|
|
|
type KeypairSource = 'provided' | 'generate';
|
|
type GeneratedKeyType = 'ed25519' | 'rsa-4096';
|
|
|
|
interface FormState {
|
|
label: string;
|
|
host: string;
|
|
port: string;
|
|
username: string;
|
|
keypairSource: KeypairSource;
|
|
generateKeyType: GeneratedKeyType;
|
|
privateKeyPem: string;
|
|
passphrase: string;
|
|
remotePathPrefix: string;
|
|
commandDenyPatterns: string;
|
|
commandAllowPatterns: string;
|
|
allowRemoteUnrestricted: boolean;
|
|
allowPrivateAddresses: boolean;
|
|
reason: string;
|
|
}
|
|
|
|
function initialFromExisting(existing: SshConnection | null): FormState {
|
|
return {
|
|
label: existing?.label ?? '',
|
|
host: existing?.host ?? '',
|
|
port: existing ? String(existing.port) : '22',
|
|
username: existing?.username ?? '',
|
|
keypairSource: 'provided',
|
|
generateKeyType: 'ed25519',
|
|
privateKeyPem: '',
|
|
passphrase: '',
|
|
remotePathPrefix: existing?.remotePathPrefix ?? '/srv/agent',
|
|
commandDenyPatterns: existing?.commandDenyPatterns ?? '',
|
|
commandAllowPatterns: existing?.commandAllowPatterns ?? '',
|
|
allowRemoteUnrestricted: existing?.allowRemoteUnrestricted ?? false,
|
|
allowPrivateAddresses: existing?.allowPrivateAddresses ?? false,
|
|
reason: '',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create/edit form for an SSH connection.
|
|
*
|
|
* - Create: `existing === null`, all required fields visible, privateKeyPem required.
|
|
* - Edit: `existing !== null`, privateKeyPem optional (omitted = keep current key).
|
|
* - Admin context shows `allowRemoteUnrestricted` and `allowPrivateAddresses` toggles.
|
|
* These flags are admin-only at the API layer; user-context renders them omitted.
|
|
* - When `adminContext && isCreate`: also collect `reason` (required ≥ 8 chars) for the
|
|
* audit row that POST /admin/globals will write. Edit reasons go via PATCH.
|
|
*/
|
|
export function SshConnectionForm({ existing, adminContext, onSubmit, onCancel }: SshConnectionFormProps) {
|
|
const isCreate = existing === null;
|
|
const [state, setState] = useState<FormState>(() => initialFromExisting(existing));
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
setState(initialFromExisting(existing));
|
|
setError(null);
|
|
}, [existing?.id]);
|
|
|
|
function update<K extends keyof FormState>(key: K, value: FormState[K]) {
|
|
setState(prev => ({ ...prev, [key]: value }));
|
|
}
|
|
|
|
const portNum = Number(state.port);
|
|
const portValid = Number.isInteger(portNum) && portNum >= 1 && portNum <= 65535;
|
|
const remotePathOk = state.allowRemoteUnrestricted
|
|
? true
|
|
: (() => {
|
|
const p = state.remotePathPrefix.trim();
|
|
if (p.length === 0) return false;
|
|
// Accept POSIX (`/srv/agent`), Windows drive (`C:\Users\agent`),
|
|
// UNC (`\\server\share`), or no-leading-slash prefixes. Reject any
|
|
// `..` parent-ref segment in either separator.
|
|
return !p.split(/[\\/]/).includes('..');
|
|
})();
|
|
const needsUploadedKey = isCreate && state.keypairSource === 'provided';
|
|
const baseValid =
|
|
state.label.trim().length > 0 &&
|
|
state.host.trim().length > 0 &&
|
|
state.username.trim().length > 0 &&
|
|
portValid &&
|
|
remotePathOk &&
|
|
(!needsUploadedKey || state.privateKeyPem.length > 0);
|
|
const reasonNeeded = adminContext;
|
|
const reasonValid = !reasonNeeded || state.reason.trim().length >= 8;
|
|
const canSubmit = baseValid && reasonValid && !submitting;
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!canSubmit) return;
|
|
setSubmitting(true);
|
|
setError(null);
|
|
|
|
const body: Record<string, unknown> = {
|
|
label: state.label.trim(),
|
|
host: state.host.trim(),
|
|
port: portNum,
|
|
username: state.username.trim(),
|
|
};
|
|
// Keypair handling. Only meaningful at create-time; on edit we never
|
|
// re-key (a separate rotation flow handles that).
|
|
if (isCreate && state.keypairSource === 'generate') {
|
|
body.keypairSource = 'generate';
|
|
body.generateKeyType = state.generateKeyType;
|
|
} else {
|
|
if (state.privateKeyPem.length > 0) {
|
|
body.privateKeyPem = state.privateKeyPem;
|
|
}
|
|
if (state.passphrase.length > 0) {
|
|
body.passphrase = state.passphrase;
|
|
}
|
|
}
|
|
if (state.allowRemoteUnrestricted && adminContext) {
|
|
body.allowRemoteUnrestricted = true;
|
|
} else {
|
|
body.remotePathPrefix = state.remotePathPrefix.trim();
|
|
}
|
|
if (adminContext && state.allowPrivateAddresses) {
|
|
body.allowPrivateAddresses = true;
|
|
}
|
|
if (state.commandDenyPatterns.trim().length > 0) {
|
|
body.commandDenyPatterns = state.commandDenyPatterns.trim();
|
|
} else if (existing?.commandDenyPatterns) {
|
|
body.commandDenyPatterns = '';
|
|
}
|
|
if (state.commandAllowPatterns.trim().length > 0) {
|
|
body.commandAllowPatterns = state.commandAllowPatterns.trim();
|
|
} else if (existing?.commandAllowPatterns) {
|
|
body.commandAllowPatterns = '';
|
|
}
|
|
if (reasonNeeded) {
|
|
body.reason = state.reason.trim();
|
|
}
|
|
|
|
try {
|
|
await onSubmit(body);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className="space-y-3">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="Label" required>
|
|
<input
|
|
type="text"
|
|
value={state.label}
|
|
onChange={e => update('label', e.target.value)}
|
|
className={inputCls}
|
|
placeholder="prod-db (任意の表示名)"
|
|
required
|
|
/>
|
|
</Field>
|
|
<Field label="Username" required>
|
|
<input
|
|
type="text"
|
|
value={state.username}
|
|
onChange={e => update('username', e.target.value)}
|
|
className={inputCls + ' font-mono'}
|
|
placeholder="agent"
|
|
required
|
|
/>
|
|
</Field>
|
|
<Field label="Host" required>
|
|
<input
|
|
type="text"
|
|
value={state.host}
|
|
onChange={e => update('host', e.target.value)}
|
|
className={inputCls + ' font-mono'}
|
|
placeholder="db.example.com"
|
|
required
|
|
/>
|
|
</Field>
|
|
<Field label="Port" required>
|
|
<input
|
|
type="number"
|
|
value={state.port}
|
|
onChange={e => update('port', e.target.value)}
|
|
className={inputCls + ' font-mono'}
|
|
min={1}
|
|
max={65535}
|
|
required
|
|
/>
|
|
</Field>
|
|
</div>
|
|
|
|
{isCreate && (
|
|
<fieldset className="rounded border border-hairline bg-surface/40 p-3 space-y-2">
|
|
<legend className="px-1 text-2xs font-semibold text-slate-600 uppercase tracking-wide">
|
|
鍵の出所
|
|
</legend>
|
|
<label className="flex items-start gap-2 text-xs cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="keypairSource"
|
|
value="provided"
|
|
checked={state.keypairSource === 'provided'}
|
|
onChange={() => update('keypairSource', 'provided')}
|
|
className="mt-0.5"
|
|
/>
|
|
<span>
|
|
<span className="font-semibold">既存の秘密鍵を貼り付け</span>
|
|
<span className="block text-2xs text-slate-600">
|
|
ローカルで <code className="font-mono">ssh-keygen</code> して作成済みの鍵を upload します。
|
|
</span>
|
|
</span>
|
|
</label>
|
|
<label className="flex items-start gap-2 text-xs cursor-pointer">
|
|
<input
|
|
type="radio"
|
|
name="keypairSource"
|
|
value="generate"
|
|
checked={state.keypairSource === 'generate'}
|
|
onChange={() => update('keypairSource', 'generate')}
|
|
className="mt-0.5"
|
|
/>
|
|
<span>
|
|
<span className="font-semibold">Orchestrator で新規生成</span>
|
|
<span className="block text-2xs text-slate-600">
|
|
秘密鍵は Orchestrator が保持し、作成後に公開鍵が表示されます。それを接続先の
|
|
<code className="font-mono"> ~/.ssh/authorized_keys</code> に追加してください。
|
|
</span>
|
|
</span>
|
|
</label>
|
|
{state.keypairSource === 'generate' && (
|
|
<Field label="Key type">
|
|
<select
|
|
value={state.generateKeyType}
|
|
onChange={e => update('generateKeyType', e.target.value as GeneratedKeyType)}
|
|
className={inputCls}
|
|
>
|
|
<option value="ed25519">Ed25519 (recommended, 推奨)</option>
|
|
<option value="rsa-4096">RSA 4096-bit (互換性重視)</option>
|
|
</select>
|
|
</Field>
|
|
)}
|
|
</fieldset>
|
|
)}
|
|
|
|
{(!isCreate || state.keypairSource === 'provided') && (
|
|
<>
|
|
<Field
|
|
label={`Private Key (PEM)${isCreate ? '' : ' ← 空欄なら現在のキーを維持'}`}
|
|
required={isCreate && state.keypairSource === 'provided'}
|
|
>
|
|
<textarea
|
|
value={state.privateKeyPem}
|
|
onChange={e => update('privateKeyPem', e.target.value)}
|
|
className={inputCls + ' font-mono h-32 resize-y'}
|
|
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"
|
|
spellCheck={false}
|
|
autoComplete="off"
|
|
required={isCreate && state.keypairSource === 'provided'}
|
|
/>
|
|
<p className="text-2xs text-slate-500 mt-1">
|
|
サーバー側で保存時に AES-256-GCM 暗号化されます。SHA1 ベースの RSA キーは検出時に reject されます。
|
|
</p>
|
|
</Field>
|
|
|
|
<Field label="Passphrase ← キーが encrypted な場合のみ">
|
|
<input
|
|
type="password"
|
|
value={state.passphrase}
|
|
onChange={e => update('passphrase', e.target.value)}
|
|
className={inputCls + ' font-mono'}
|
|
autoComplete="new-password"
|
|
/>
|
|
</Field>
|
|
</>
|
|
)}
|
|
|
|
{!state.allowRemoteUnrestricted && (
|
|
<Field label="Remote Path Prefix" required>
|
|
<input
|
|
type="text"
|
|
value={state.remotePathPrefix}
|
|
onChange={e => update('remotePathPrefix', e.target.value)}
|
|
className={inputCls + ' font-mono'}
|
|
placeholder="/srv/agent"
|
|
required
|
|
/>
|
|
<p className="text-2xs text-slate-500 mt-1">
|
|
ファイル系オペレーション (Upload/Download) はこのプレフィックス配下に限定されます。
|
|
</p>
|
|
</Field>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<Field label="Command Deny Patterns (1 行 1 regex)">
|
|
<textarea
|
|
value={state.commandDenyPatterns}
|
|
onChange={e => update('commandDenyPatterns', e.target.value)}
|
|
className={inputCls + ' font-mono h-20 resize-y text-2xs'}
|
|
placeholder="^rm\s+-rf\s+/ ^dd\s"
|
|
spellCheck={false}
|
|
/>
|
|
</Field>
|
|
<Field label="Command Allow Patterns (空 = 全許可)">
|
|
<textarea
|
|
value={state.commandAllowPatterns}
|
|
onChange={e => update('commandAllowPatterns', e.target.value)}
|
|
className={inputCls + ' font-mono h-20 resize-y text-2xs'}
|
|
placeholder="^psql\s ^ls\b"
|
|
spellCheck={false}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
|
|
{adminContext && (
|
|
<fieldset className="rounded border border-amber-200 bg-amber-50/50 p-3">
|
|
<legend className="px-1 text-2xs font-semibold text-amber-800 uppercase tracking-wide">
|
|
Admin-only flags
|
|
</legend>
|
|
<label className="flex items-start gap-2 text-xs cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={state.allowRemoteUnrestricted}
|
|
onChange={e => update('allowRemoteUnrestricted', e.target.checked)}
|
|
className="mt-0.5"
|
|
/>
|
|
<span>
|
|
<span className="font-semibold">Allow remote unrestricted</span>
|
|
<span className="block text-2xs text-slate-600">
|
|
Remote Path Prefix を無効化。本当に必要な場合のみ。
|
|
</span>
|
|
</span>
|
|
</label>
|
|
<label className="flex items-start gap-2 text-xs cursor-pointer mt-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={state.allowPrivateAddresses}
|
|
onChange={e => update('allowPrivateAddresses', e.target.checked)}
|
|
className="mt-0.5"
|
|
/>
|
|
<span>
|
|
<span className="font-semibold">Allow private addresses</span>
|
|
<span className="block text-2xs text-slate-600">
|
|
RFC1918 / localhost への接続を許可。SSRF ガードを緩める設定。
|
|
</span>
|
|
</span>
|
|
</label>
|
|
</fieldset>
|
|
)}
|
|
|
|
{reasonNeeded && (
|
|
<Field label="Reason (≥ 8 chars; 監査ログに残ります)" required>
|
|
<input
|
|
type="text"
|
|
value={state.reason}
|
|
onChange={e => update('reason', e.target.value)}
|
|
className={inputCls}
|
|
placeholder="新規ステージング用 global 接続を追加"
|
|
required
|
|
/>
|
|
</Field>
|
|
)}
|
|
|
|
{error && <div className="text-xs text-red-600">{error}</div>}
|
|
|
|
<div className="flex items-center justify-end gap-2 pt-2 border-t border-hairline">
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
disabled={submitting}
|
|
className="px-3 h-7 text-xs text-slate-700 border border-hairline bg-canvas rounded-md hover:bg-surface disabled:opacity-50"
|
|
>
|
|
キャンセル
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={!canSubmit}
|
|
className="px-3 h-7 text-xs font-semibold bg-accent text-accent-fg rounded-md hover:bg-accent-deep disabled:opacity-50"
|
|
>
|
|
{submitting ? '保存中…' : isCreate ? '作成' : '更新'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|
|
|
|
const inputCls = 'w-full text-xs px-2 py-1.5 border border-hairline rounded';
|
|
|
|
function Field({ label, required, children }: { label: string; required?: boolean; children: React.ReactNode }) {
|
|
return (
|
|
<label className="block">
|
|
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide mb-1">
|
|
{label}
|
|
{required && <span className="text-red-500 ml-1">*</span>}
|
|
</div>
|
|
{children}
|
|
</label>
|
|
);
|
|
}
|