Add SSH tunnel support to Lamassu configuration: update database schema, models, and UI components to include SSH settings. Implement SSH tunnel setup and teardown in transaction processing for enhanced security.

This commit is contained in:
padreug 2025-06-18 13:40:30 +02:00
parent 1f7999a556
commit 8f046ad0c5
6 changed files with 287 additions and 14 deletions

14
crud.py
View file

@ -308,8 +308,10 @@ async def create_lamassu_config(data: CreateLamassuConfigData) -> LamassuConfig:
await db.execute(
"""
INSERT INTO myextension.lamassu_config
(id, host, port, database_name, username, password, is_active, created_at, updated_at)
VALUES (:id, :host, :port, :database_name, :username, :password, :is_active, :created_at, :updated_at)
(id, host, port, database_name, username, password, is_active, created_at, updated_at,
use_ssh_tunnel, ssh_host, ssh_port, ssh_username, ssh_password, ssh_private_key)
VALUES (:id, :host, :port, :database_name, :username, :password, :is_active, :created_at, :updated_at,
:use_ssh_tunnel, :ssh_host, :ssh_port, :ssh_username, :ssh_password, :ssh_private_key)
""",
{
"id": config_id,
@ -320,7 +322,13 @@ async def create_lamassu_config(data: CreateLamassuConfigData) -> LamassuConfig:
"password": data.password,
"is_active": True,
"created_at": datetime.now(),
"updated_at": datetime.now()
"updated_at": datetime.now(),
"use_ssh_tunnel": data.use_ssh_tunnel,
"ssh_host": data.ssh_host,
"ssh_port": data.ssh_port,
"ssh_username": data.ssh_username,
"ssh_password": data.ssh_password,
"ssh_private_key": data.ssh_private_key
}
)
return await get_lamassu_config(config_id)

View file

@ -116,3 +116,45 @@ async def m006_create_lamassu_config(db):
);
"""
)
async def m007_add_ssh_tunnel_support(db):
"""
Add SSH tunnel support to Lamassu configuration table.
"""
await db.execute(
"""
ALTER TABLE myextension.lamassu_config
ADD COLUMN use_ssh_tunnel BOOLEAN NOT NULL DEFAULT false;
"""
)
await db.execute(
"""
ALTER TABLE myextension.lamassu_config
ADD COLUMN ssh_host TEXT;
"""
)
await db.execute(
"""
ALTER TABLE myextension.lamassu_config
ADD COLUMN ssh_port INTEGER NOT NULL DEFAULT 22;
"""
)
await db.execute(
"""
ALTER TABLE myextension.lamassu_config
ADD COLUMN ssh_username TEXT;
"""
)
await db.execute(
"""
ALTER TABLE myextension.lamassu_config
ADD COLUMN ssh_password TEXT;
"""
)
await db.execute(
"""
ALTER TABLE myextension.lamassu_config
ADD COLUMN ssh_private_key TEXT;
"""
)

View file

@ -106,6 +106,13 @@ class CreateLamassuConfigData(BaseModel):
database_name: str
username: str
password: str
# SSH Tunnel settings
use_ssh_tunnel: bool = False
ssh_host: Optional[str] = None
ssh_port: int = 22
ssh_username: Optional[str] = None
ssh_password: Optional[str] = None
ssh_private_key: Optional[str] = None # Path to private key file or key content
class LamassuConfig(BaseModel):
@ -120,6 +127,13 @@ class LamassuConfig(BaseModel):
test_connection_success: Optional[bool]
created_at: datetime
updated_at: datetime
# SSH Tunnel settings
use_ssh_tunnel: bool = False
ssh_host: Optional[str] = None
ssh_port: int = 22
ssh_username: Optional[str] = None
ssh_password: Optional[str] = None
ssh_private_key: Optional[str] = None
class UpdateLamassuConfigData(BaseModel):
@ -129,6 +143,13 @@ class UpdateLamassuConfigData(BaseModel):
username: Optional[str] = None
password: Optional[str] = None
is_active: Optional[bool] = None
# SSH Tunnel settings
use_ssh_tunnel: Optional[bool] = None
ssh_host: Optional[str] = None
ssh_port: Optional[int] = None
ssh_username: Optional[str] = None
ssh_password: Optional[str] = None
ssh_private_key: Optional[str] = None
# Legacy models (keep for backward compatibility during transition)

View file

@ -69,7 +69,14 @@ window.app = Vue.createApp({
port: 5432,
database_name: '',
username: '',
password: ''
password: '',
// SSH Tunnel settings
use_ssh_tunnel: false,
ssh_host: '',
ssh_port: 22,
ssh_username: '',
ssh_password: '',
ssh_private_key: ''
}
},
@ -148,7 +155,14 @@ window.app = Vue.createApp({
port: this.configDialog.data.port,
database_name: this.configDialog.data.database_name,
username: this.configDialog.data.username,
password: this.configDialog.data.password
password: this.configDialog.data.password,
// SSH Tunnel settings
use_ssh_tunnel: this.configDialog.data.use_ssh_tunnel,
ssh_host: this.configDialog.data.ssh_host,
ssh_port: this.configDialog.data.ssh_port,
ssh_username: this.configDialog.data.ssh_username,
ssh_password: this.configDialog.data.ssh_password,
ssh_private_key: this.configDialog.data.ssh_private_key
}
const {data: config} = await LNbits.api.request(
@ -178,7 +192,14 @@ window.app = Vue.createApp({
port: 5432,
database_name: '',
username: '',
password: ''
password: '',
// SSH Tunnel settings
use_ssh_tunnel: false,
ssh_host: '',
ssh_port: 22,
ssh_username: '',
ssh_password: '',
ssh_private_key: ''
}
},
@ -640,6 +661,22 @@ window.app = Vue.createApp({
},
computed: {
isConfigFormValid() {
const data = this.configDialog.data
// Basic database fields are required
const basicValid = data.host && data.database_name && data.username
// If SSH tunnel is enabled, validate SSH fields
if (data.use_ssh_tunnel) {
const sshValid = data.ssh_host && data.ssh_username &&
(data.ssh_password || data.ssh_private_key)
return basicValid && sshValid
}
return basicValid
},
clientOptions() {
return this.dcaClients.map(client => ({
label: `${client.user_id.substring(0, 8)}... (${client.dca_mode})`,

View file

@ -558,6 +558,83 @@
hint="Database password"
></q-input>
<q-separator class="q-my-md"></q-separator>
<div class="text-h6 q-mb-md">SSH Tunnel (Recommended)</div>
<div class="row items-center q-mb-md">
<q-toggle
v-model="configDialog.data.use_ssh_tunnel"
color="primary"
@click.stop
/>
<span class="q-ml-sm">Use SSH Tunnel</span>
</div>
<div v-if="configDialog.data.use_ssh_tunnel" class="q-mt-md" @click.stop>
<q-input
filled
dense
v-model.trim="configDialog.data.ssh_host"
label="SSH Host *"
placeholder="e.g., your-server.com or 192.168.1.100"
hint="SSH server hostname or IP address"
@click.stop
></q-input>
<q-input
filled
dense
type="number"
v-model.number="configDialog.data.ssh_port"
label="SSH Port *"
placeholder="22"
hint="SSH port (usually 22)"
@click.stop
></q-input>
<q-input
filled
dense
v-model.trim="configDialog.data.ssh_username"
label="SSH Username *"
placeholder="ubuntu"
hint="SSH username"
@click.stop
></q-input>
<q-input
filled
dense
type="password"
v-model.trim="configDialog.data.ssh_password"
label="SSH Password"
placeholder="SSH password (if not using key)"
hint="SSH password or leave empty to use private key"
@click.stop
></q-input>
<q-input
filled
dense
type="textarea"
v-model.trim="configDialog.data.ssh_private_key"
label="SSH Private Key"
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
hint="SSH private key content (alternative to password)"
rows="4"
@click.stop
></q-input>
<q-banner class="bg-green-1 text-green-9 q-mt-md">
<template v-slot:avatar>
<q-icon name="security" color="green" />
</template>
SSH tunneling keeps your database secure by avoiding direct internet exposure.
The database connection will be routed through the SSH server.
</q-banner>
</div>
<q-banner v-if="!configDialog.data.id" class="bg-blue-1 text-blue-9">
<template v-slot:avatar>
<q-icon name="info" color="blue" />
@ -571,10 +648,11 @@
unelevated
color="primary"
type="submit"
:disable="!configDialog.data.host || !configDialog.data.database_name || !configDialog.data.username"
:disable="!isConfigFormValid"
@click.stop
>Save Configuration</q-btn
>
<q-btn v-close-popup flat color="grey" class="q-ml-auto"
<q-btn v-close-popup flat color="grey" class="q-ml-auto" @click.stop
>Cancel</q-btn
>
</div>

View file

@ -5,6 +5,17 @@ import asyncpg
from datetime import datetime, timedelta
from typing import List, Optional, Dict, Any
from loguru import logger
import socket
import threading
import time
try:
import paramiko
from sshtunnel import SSHTunnelForwarder
SSH_AVAILABLE = True
except ImportError:
SSH_AVAILABLE = False
logger.warning("SSH tunnel support not available. Install paramiko and sshtunnel: pip install paramiko sshtunnel")
from lnbits.core.services import create_invoice, pay_invoice
from lnbits.settings import settings
@ -26,6 +37,7 @@ class LamassuTransactionProcessor:
def __init__(self):
self.last_check_time = None
self.processed_transaction_ids = set()
self.ssh_tunnel = None
async def get_db_config(self) -> Optional[Dict[str, Any]]:
"""Get database configuration from the database"""
@ -41,12 +53,78 @@ class LamassuTransactionProcessor:
"database": config.database_name,
"user": config.username,
"password": config.password,
"config_id": config.id
"config_id": config.id,
"use_ssh_tunnel": config.use_ssh_tunnel,
"ssh_host": config.ssh_host,
"ssh_port": config.ssh_port,
"ssh_username": config.ssh_username,
"ssh_password": config.ssh_password,
"ssh_private_key": config.ssh_private_key
}
except Exception as e:
logger.error(f"Error getting database configuration: {e}")
return None
def setup_ssh_tunnel(self, db_config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Setup SSH tunnel if required and return modified connection config"""
if not db_config.get("use_ssh_tunnel"):
return db_config
if not SSH_AVAILABLE:
logger.error("SSH tunnel requested but paramiko/sshtunnel not available")
return None
try:
# Close existing tunnel if any
self.close_ssh_tunnel()
ssh_config = {
"ssh_address_or_host": (db_config["ssh_host"], db_config["ssh_port"]),
"remote_bind_address": (db_config["host"], db_config["port"]),
"ssh_username": db_config["ssh_username"],
"local_bind_address": ("127.0.0.1",) # Let sshtunnel choose local port
}
# Add authentication method
if db_config.get("ssh_private_key"):
# Use private key authentication
ssh_config["ssh_pkey"] = db_config["ssh_private_key"]
elif db_config.get("ssh_password"):
# Use password authentication
ssh_config["ssh_password"] = db_config["ssh_password"]
else:
logger.error("SSH tunnel requires either private key or password")
return None
self.ssh_tunnel = SSHTunnelForwarder(**ssh_config)
self.ssh_tunnel.start()
local_port = self.ssh_tunnel.local_bind_port
logger.info(f"SSH tunnel established: localhost:{local_port} -> {db_config['ssh_host']}:{db_config['ssh_port']} -> {db_config['host']}:{db_config['port']}")
# Return modified config to connect through tunnel
tunnel_config = db_config.copy()
tunnel_config["host"] = "127.0.0.1"
tunnel_config["port"] = local_port
return tunnel_config
except Exception as e:
logger.error(f"Failed to setup SSH tunnel: {e}")
self.close_ssh_tunnel()
return None
def close_ssh_tunnel(self):
"""Close SSH tunnel if active"""
if self.ssh_tunnel:
try:
self.ssh_tunnel.stop()
logger.info("SSH tunnel closed")
except Exception as e:
logger.warning(f"Error closing SSH tunnel: {e}")
finally:
self.ssh_tunnel = None
async def connect_to_lamassu_db(self) -> Optional[asyncpg.Connection]:
"""Establish connection to Lamassu Postgres database"""
try:
@ -54,12 +132,17 @@ class LamassuTransactionProcessor:
if not db_config:
return None
# Setup SSH tunnel if required
connection_config = self.setup_ssh_tunnel(db_config)
if not connection_config:
return None
connection = await asyncpg.connect(
host=db_config["host"],
port=db_config["port"],
database=db_config["database"],
user=db_config["user"],
password=db_config["password"],
host=connection_config["host"],
port=connection_config["port"],
database=connection_config["database"],
user=connection_config["user"],
password=connection_config["password"],
timeout=30
)
logger.info("Successfully connected to Lamassu database")
@ -286,9 +369,13 @@ class LamassuTransactionProcessor:
finally:
await connection.close()
# Close SSH tunnel if it was used
self.close_ssh_tunnel()
except Exception as e:
logger.error(f"Error in polling cycle: {e}")
# Ensure cleanup on error
self.close_ssh_tunnel()
# Global processor instance