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:
|
if not vmid:
|
||||||
raise HTTPException(status_code=500, detail="Failed to get next 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(
|
upid = client.create_lxc_container(
|
||||||
node=node,
|
node=node,
|
||||||
vmid=vmid,
|
vmid=vmid,
|
||||||
ostemplate=ostemplate,
|
ostemplate=ostemplate,
|
||||||
hostname=hostname,
|
hostname=hostname,
|
||||||
password=password,
|
password=password,
|
||||||
ssh_public_keys=ssh_public_keys,
|
ssh_public_keys=combined_ssh_keys if combined_ssh_keys else None,
|
||||||
storage=storage,
|
storage=storage,
|
||||||
rootfs_size=rootfs_size,
|
rootfs_size=rootfs_size,
|
||||||
memory=memory,
|
memory=memory,
|
||||||
@@ -4550,6 +4558,7 @@ async def create_lxc_container_smart(
|
|||||||
ostemplate = data.get('ostemplate')
|
ostemplate = data.get('ostemplate')
|
||||||
hostname = data.get('hostname')
|
hostname = data.get('hostname')
|
||||||
password = data.get('password')
|
password = data.get('password')
|
||||||
|
ssh_public_keys = data.get('ssh_public_keys') # SSH keys from request
|
||||||
storage = data.get('storage', 'local-lvm')
|
storage = data.get('storage', 'local-lvm')
|
||||||
rootfs_size = data.get('rootfs_size', 8)
|
rootfs_size = data.get('rootfs_size', 8)
|
||||||
memory = data.get('memory', 512)
|
memory = data.get('memory', 512)
|
||||||
@@ -4565,8 +4574,8 @@ async def create_lxc_container_smart(
|
|||||||
if not all([template_node, target_node, ostemplate, hostname]):
|
if not all([template_node, target_node, ostemplate, hostname]):
|
||||||
raise HTTPException(status_code=400, detail="Missing required parameters")
|
raise HTTPException(status_code=400, detail="Missing required parameters")
|
||||||
|
|
||||||
if not password:
|
if not password and not ssh_public_keys and not current_user.ssh_public_key:
|
||||||
raise HTTPException(status_code=400, detail="Password is required")
|
raise HTTPException(status_code=400, detail="Password or SSH keys are required")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if server.use_password:
|
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}")
|
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(
|
upid = client.create_lxc_container(
|
||||||
node=create_node,
|
node=create_node,
|
||||||
@@ -4605,6 +4622,7 @@ async def create_lxc_container_smart(
|
|||||||
ostemplate=ostemplate,
|
ostemplate=ostemplate,
|
||||||
hostname=hostname,
|
hostname=hostname,
|
||||||
password=password,
|
password=password,
|
||||||
|
ssh_public_keys=combined_ssh_keys if combined_ssh_keys else None,
|
||||||
storage=storage,
|
storage=storage,
|
||||||
rootfs_size=rootfs_size,
|
rootfs_size=rootfs_size,
|
||||||
memory=memory,
|
memory=memory,
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ def set_setting(db: Session, key: str, value: str, description: str = None):
|
|||||||
class UpdateProfileRequest(BaseModel):
|
class UpdateProfileRequest(BaseModel):
|
||||||
full_name: Optional[str] = Field(None, max_length=100)
|
full_name: Optional[str] = Field(None, max_length=100)
|
||||||
email: Optional[EmailStr] = None
|
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):
|
class ChangePasswordRequest(BaseModel):
|
||||||
@@ -141,6 +142,7 @@ async def get_profile(
|
|||||||
"full_name": current_user.full_name,
|
"full_name": current_user.full_name,
|
||||||
"is_admin": current_user.is_admin,
|
"is_admin": current_user.is_admin,
|
||||||
"is_active": current_user.is_active,
|
"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,
|
"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
|
"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
|
current_user.email = data.email
|
||||||
updated_fields.append("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:
|
if updated_fields:
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(current_user)
|
db.refresh(current_user)
|
||||||
|
|||||||
@@ -764,6 +764,14 @@ async def deploy_vm_from_template(
|
|||||||
elif allocated_ip is None:
|
elif allocated_ip is None:
|
||||||
ip_config = "ip=dhcp"
|
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(
|
config_success = client.configure_vm(
|
||||||
node=deploy_node,
|
node=deploy_node,
|
||||||
vmid=new_vmid,
|
vmid=new_vmid,
|
||||||
@@ -773,7 +781,7 @@ async def deploy_vm_from_template(
|
|||||||
network_bridge=deploy_data.network_bridge,
|
network_bridge=deploy_data.network_bridge,
|
||||||
cloud_init_user=deploy_data.cloud_init_user,
|
cloud_init_user=deploy_data.cloud_init_user,
|
||||||
cloud_init_password=deploy_data.cloud_init_password,
|
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,
|
ip_config=ip_config,
|
||||||
onboot=deploy_data.onboot
|
onboot=deploy_data.onboot
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3962,6 +3962,36 @@ class I18nService:
|
|||||||
"en": "Password must contain at least one special character"
|
"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
|
||||||
"ip_blacklist": {
|
"ip_blacklist": {
|
||||||
"ru": "Чёрный список IP",
|
"ru": "Чёрный список IP",
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ class User(Base):
|
|||||||
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True, index=True)
|
role_id = Column(Integer, ForeignKey("roles.id"), nullable=True, index=True)
|
||||||
role = relationship("Role", back_populates="users", lazy="joined")
|
role = relationship("Role", back_populates="users", lazy="joined")
|
||||||
|
|
||||||
|
# SSH Public Key for VM/LXC deployment
|
||||||
|
ssh_public_key = Column(Text, nullable=True)
|
||||||
|
|
||||||
# Security fields
|
# Security fields
|
||||||
failed_login_attempts = Column(Integer, default=0, nullable=False)
|
failed_login_attempts = Column(Integer, default=0, nullable=False)
|
||||||
locked_until = Column(DateTime(timezone=True), nullable=True)
|
locked_until = Column(DateTime(timezone=True), nullable=True)
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ const translations = {
|
|||||||
no_updates: "Установлена последняя версия",
|
no_updates: "Установлена последняя версия",
|
||||||
update_warning: "Панель будет недоступна во время обновления",
|
update_warning: "Панель будет недоступна во время обновления",
|
||||||
updating_system: "Обновление системы...",
|
updating_system: "Обновление системы...",
|
||||||
|
|
||||||
|
// SSH Keys
|
||||||
|
ssh_key_saved: "SSH ключ успешно сохранён",
|
||||||
|
error_saving_ssh_key: "Ошибка сохранения SSH ключа",
|
||||||
|
invalid_ssh_key_format: "Неверный формат SSH ключа",
|
||||||
},
|
},
|
||||||
|
|
||||||
en: {
|
en: {
|
||||||
@@ -80,6 +85,11 @@ const translations = {
|
|||||||
no_updates: "You have the latest version",
|
no_updates: "You have the latest version",
|
||||||
update_warning: "Panel will be unavailable during update",
|
update_warning: "Panel will be unavailable during update",
|
||||||
updating_system: "Updating system...",
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Notifications Settings Tab -->
|
<!-- 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
|
// Panel form
|
||||||
document.getElementById('panel-form').addEventListener('submit', async function(e) {
|
document.getElementById('panel-form').addEventListener('submit', async function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1294,6 +1345,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
document.getElementById('username').value = user.username;
|
document.getElementById('username').value = user.username;
|
||||||
document.getElementById('full_name').value = user.full_name || '';
|
document.getElementById('full_name').value = user.full_name || '';
|
||||||
document.getElementById('email').value = user.email || '';
|
document.getElementById('email').value = user.email || '';
|
||||||
|
document.getElementById('ssh_public_key').value = user.ssh_public_key || '';
|
||||||
document.getElementById('profile-form').style.display = 'block';
|
document.getElementById('profile-form').style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -875,6 +875,22 @@ def migrate_snapshot_archives(conn):
|
|||||||
logger.info("✓ Snapshot archives table created")
|
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 ====================
|
# ==================== Main Migration Function ====================
|
||||||
|
|
||||||
def run_all_migrations(engine, db_session=None):
|
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}")
|
logger.warning(f"Snapshot archives migration: {e}")
|
||||||
conn.rollback()
|
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("=" * 50)
|
||||||
logger.info("✓ All migrations completed successfully")
|
logger.info("✓ All migrations completed successfully")
|
||||||
logger.info("=" * 50)
|
logger.info("=" * 50)
|
||||||
|
|||||||
Reference in New Issue
Block a user