feat: Add SSH public key storage for users

- Add ssh_public_key column to User model for storing SSH keys
- Add database migration for ssh_public_key column
- Add SSH key section in user profile settings with validation
- Auto-inject user's SSH key when deploying VMs from templates
- Auto-inject user's SSH key when creating LXC containers
- Support for both regular and smart LXC creation endpoints
- Add i18n translations for SSH key UI (ru/en)
- Validate SSH key format (ssh-rsa, ssh-ed25519, ecdsa-sha2, ssh-dss)

Users can now save their SSH public key in Settings -> Account,
and it will be automatically added to any VM or LXC container
they create, enabling passwordless SSH access.
This commit is contained in:
2025-12-15 20:22:42 +05:00
parent 2b78d93710
commit 379afd0353
8 changed files with 162 additions and 4 deletions

View File

@@ -4475,13 +4475,21 @@ async def create_lxc_container(
if not vmid:
raise HTTPException(status_code=500, detail="Failed to get next VMID")
# Combine user's SSH key with request SSH keys
combined_ssh_keys = ssh_public_keys or ""
if current_user.ssh_public_key:
if combined_ssh_keys:
combined_ssh_keys = f"{combined_ssh_keys}\n{current_user.ssh_public_key}"
else:
combined_ssh_keys = current_user.ssh_public_key
upid = client.create_lxc_container(
node=node,
vmid=vmid,
ostemplate=ostemplate,
hostname=hostname,
password=password,
ssh_public_keys=ssh_public_keys,
ssh_public_keys=combined_ssh_keys if combined_ssh_keys else None,
storage=storage,
rootfs_size=rootfs_size,
memory=memory,
@@ -4550,6 +4558,7 @@ async def create_lxc_container_smart(
ostemplate = data.get('ostemplate')
hostname = data.get('hostname')
password = data.get('password')
ssh_public_keys = data.get('ssh_public_keys') # SSH keys from request
storage = data.get('storage', 'local-lvm')
rootfs_size = data.get('rootfs_size', 8)
memory = data.get('memory', 512)
@@ -4565,8 +4574,8 @@ async def create_lxc_container_smart(
if not all([template_node, target_node, ostemplate, hostname]):
raise HTTPException(status_code=400, detail="Missing required parameters")
if not password:
raise HTTPException(status_code=400, detail="Password is required")
if not password and not ssh_public_keys and not current_user.ssh_public_key:
raise HTTPException(status_code=400, detail="Password or SSH keys are required")
try:
if server.use_password:
@@ -4598,6 +4607,14 @@ async def create_lxc_container_smart(
logger.info(f"[LXC Smart Create] Template on {template_node}, target {target_node}, shared={is_shared}, needs_migration={needs_migration}, net0={net0}")
# Combine user's SSH key with request SSH keys
combined_ssh_keys = ssh_public_keys or ""
if current_user.ssh_public_key:
if combined_ssh_keys:
combined_ssh_keys = f"{combined_ssh_keys}\n{current_user.ssh_public_key}"
else:
combined_ssh_keys = current_user.ssh_public_key
# Создаём контейнер
upid = client.create_lxc_container(
node=create_node,
@@ -4605,6 +4622,7 @@ async def create_lxc_container_smart(
ostemplate=ostemplate,
hostname=hostname,
password=password,
ssh_public_keys=combined_ssh_keys if combined_ssh_keys else None,
storage=storage,
rootfs_size=rootfs_size,
memory=memory,

View File

@@ -64,6 +64,7 @@ def set_setting(db: Session, key: str, value: str, description: str = None):
class UpdateProfileRequest(BaseModel):
full_name: Optional[str] = Field(None, max_length=100)
email: Optional[EmailStr] = None
ssh_public_key: Optional[str] = Field(None, max_length=10000, description="SSH public key for VM/LXC deployment")
class ChangePasswordRequest(BaseModel):
@@ -141,6 +142,7 @@ async def get_profile(
"full_name": current_user.full_name,
"is_admin": current_user.is_admin,
"is_active": current_user.is_active,
"ssh_public_key": current_user.ssh_public_key,
"created_at": current_user.created_at.isoformat() if current_user.created_at else None,
"last_login": current_user.last_login.isoformat() if current_user.last_login else None
}
@@ -173,6 +175,17 @@ async def update_profile(
current_user.email = data.email
updated_fields.append("email")
if data.ssh_public_key is not None:
# Validate SSH key format (basic validation)
ssh_key = data.ssh_public_key.strip()
if ssh_key and not ssh_key.startswith(('ssh-rsa', 'ssh-ed25519', 'ecdsa-sha2', 'ssh-dss')):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Неверный формат SSH ключа. Ключ должен начинаться с ssh-rsa, ssh-ed25519, ecdsa-sha2 или ssh-dss"
)
current_user.ssh_public_key = ssh_key if ssh_key else None
updated_fields.append("ssh_public_key")
if updated_fields:
db.commit()
db.refresh(current_user)

View File

@@ -764,6 +764,14 @@ async def deploy_vm_from_template(
elif allocated_ip is None:
ip_config = "ip=dhcp"
# Combine user's SSH key with deploy request SSH keys
ssh_keys = deploy_data.ssh_keys or ""
if current_user.ssh_public_key:
if ssh_keys:
ssh_keys = f"{ssh_keys}\n{current_user.ssh_public_key}"
else:
ssh_keys = current_user.ssh_public_key
config_success = client.configure_vm(
node=deploy_node,
vmid=new_vmid,
@@ -773,7 +781,7 @@ async def deploy_vm_from_template(
network_bridge=deploy_data.network_bridge,
cloud_init_user=deploy_data.cloud_init_user,
cloud_init_password=deploy_data.cloud_init_password,
ssh_keys=deploy_data.ssh_keys,
ssh_keys=ssh_keys if ssh_keys else None,
ip_config=ip_config,
onboot=deploy_data.onboot
)

View File

@@ -3962,6 +3962,36 @@ class I18nService:
"en": "Password must contain at least one special character"
},
# SSH Public Key
"ssh_public_key": {
"ru": "SSH Публичный ключ",
"en": "SSH Public Key"
},
"ssh_public_key_label": {
"ru": "Ваш SSH публичный ключ",
"en": "Your SSH Public Key"
},
"ssh_public_key_help": {
"ru": "Этот ключ будет автоматически добавляться при создании VM/LXC. Поддерживаются ключи ssh-rsa, ssh-ed25519, ecdsa.",
"en": "This key will be automatically added when creating VM/LXC instances. Supports ssh-rsa, ssh-ed25519, ecdsa keys."
},
"save_ssh_key": {
"ru": "Сохранить ключ",
"en": "Save Key"
},
"ssh_key_saved": {
"ru": "SSH ключ успешно сохранён",
"en": "SSH key saved successfully"
},
"error_saving_ssh_key": {
"ru": "Ошибка сохранения SSH ключа",
"en": "Error saving SSH key"
},
"invalid_ssh_key_format": {
"ru": "Неверный формат SSH ключа. Ключ должен начинаться с ssh-rsa, ssh-ed25519, ecdsa-sha2 или ssh-dss",
"en": "Invalid SSH key format. Key must start with ssh-rsa, ssh-ed25519, ecdsa-sha2 or ssh-dss"
},
# IP Blacklist
"ip_blacklist": {
"ru": "Чёрный список IP",

View File

@@ -58,6 +58,9 @@ class User(Base):
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True, index=True)
role = relationship("Role", back_populates="users", lazy="joined")
# SSH Public Key for VM/LXC deployment
ssh_public_key = Column(Text, nullable=True)
# Security fields
failed_login_attempts = Column(Integer, default=0, nullable=False)
locked_until = Column(DateTime(timezone=True), nullable=True)

View File

@@ -41,6 +41,11 @@ const translations = {
no_updates: "Установлена последняя версия",
update_warning: "Панель будет недоступна во время обновления",
updating_system: "Обновление системы...",
// SSH Keys
ssh_key_saved: "SSH ключ успешно сохранён",
error_saving_ssh_key: "Ошибка сохранения SSH ключа",
invalid_ssh_key_format: "Неверный формат SSH ключа",
},
en: {
@@ -80,6 +85,11 @@ const translations = {
no_updates: "You have the latest version",
update_warning: "Panel will be unavailable during update",
updating_system: "Updating system...",
// SSH Keys
ssh_key_saved: "SSH key saved successfully",
error_saving_ssh_key: "Error saving SSH key",
invalid_ssh_key_format: "Invalid SSH key format",
}
};

View File

@@ -78,6 +78,22 @@
</button>
</form>
</div>
<div class="settings-section">
<h2><i class="fa-solid fa-key"></i> {{ t('ssh_public_key') }}</h2>
<form id="ssh-key-form" class="settings-form">
<div class="form-group">
<label for="ssh_public_key">{{ t('ssh_public_key_label') }}</label>
<textarea id="ssh_public_key" rows="4" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAA... user@hostname" style="font-family: monospace; font-size: 0.85rem;"></textarea>
<small>{{ t('ssh_public_key_help') }}</small>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> {{ t('save_ssh_key') }}
</button>
</form>
</div>
</div>
<!-- Notifications Settings Tab -->
@@ -1139,6 +1155,41 @@ document.addEventListener('DOMContentLoaded', function() {
}
});
// SSH Key form
document.getElementById('ssh-key-form').addEventListener('submit', async function(e) {
e.preventDefault();
const sshKey = document.getElementById('ssh_public_key').value.trim();
// Basic validation
if (sshKey && !sshKey.match(/^(ssh-rsa|ssh-ed25519|ecdsa-sha2|ssh-dss)\s+\S+/)) {
showNotification(t('invalid_ssh_key_format'), 'error');
return;
}
const data = {
ssh_public_key: sshKey || null
};
try {
const response = await fetchWithAuth('/settings/api/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
if (response.ok) {
showNotification(t('ssh_key_saved'), 'success');
} else {
const error = await response.json();
showNotification(error.detail || t('error_saving_ssh_key'), 'error');
}
} catch (error) {
console.error('Error:', error);
showNotification(t('error_saving_ssh_key'), 'error');
}
});
// Panel form
document.getElementById('panel-form').addEventListener('submit', async function(e) {
e.preventDefault();
@@ -1294,6 +1345,7 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('username').value = user.username;
document.getElementById('full_name').value = user.full_name || '';
document.getElementById('email').value = user.email || '';
document.getElementById('ssh_public_key').value = user.ssh_public_key || '';
document.getElementById('profile-form').style.display = 'block';
}