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:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -875,6 +875,22 @@ def migrate_snapshot_archives(conn):
|
||||
logger.info("✓ Snapshot archives table created")
|
||||
|
||||
|
||||
# ==================== Migration 12: User SSH Public Key ====================
|
||||
|
||||
def migrate_user_ssh_key(conn):
|
||||
"""Add SSH public key column to users table"""
|
||||
|
||||
if column_exists(conn, 'users', 'ssh_public_key'):
|
||||
logger.info("✓ User SSH public key column already exists")
|
||||
return
|
||||
|
||||
logger.info("Adding SSH public key column to users table...")
|
||||
|
||||
add_column_if_not_exists(conn, 'users', 'ssh_public_key', 'TEXT')
|
||||
|
||||
logger.info("✓ User SSH public key column added")
|
||||
|
||||
|
||||
# ==================== Main Migration Function ====================
|
||||
|
||||
def run_all_migrations(engine, db_session=None):
|
||||
@@ -979,6 +995,14 @@ def run_all_migrations(engine, db_session=None):
|
||||
logger.warning(f"Snapshot archives migration: {e}")
|
||||
conn.rollback()
|
||||
|
||||
# Migration 12: User SSH Public Key
|
||||
try:
|
||||
migrate_user_ssh_key(conn)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"User SSH key migration: {e}")
|
||||
conn.rollback()
|
||||
|
||||
logger.info("=" * 50)
|
||||
logger.info("✓ All migrations completed successfully")
|
||||
logger.info("=" * 50)
|
||||
|
||||
Reference in New Issue
Block a user