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( await db.execute(
""" """
INSERT INTO myextension.lamassu_config INSERT INTO myextension.lamassu_config
(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,
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)
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, "id": config_id,
@ -320,7 +322,13 @@ async def create_lamassu_config(data: CreateLamassuConfigData) -> LamassuConfig:
"password": data.password, "password": data.password,
"is_active": True, "is_active": True,
"created_at": datetime.now(), "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) 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 database_name: str
username: str username: str
password: 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): class LamassuConfig(BaseModel):
@ -120,6 +127,13 @@ class LamassuConfig(BaseModel):
test_connection_success: Optional[bool] test_connection_success: Optional[bool]
created_at: datetime created_at: datetime
updated_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): class UpdateLamassuConfigData(BaseModel):
@ -129,6 +143,13 @@ class UpdateLamassuConfigData(BaseModel):
username: Optional[str] = None username: Optional[str] = None
password: Optional[str] = None password: Optional[str] = None
is_active: Optional[bool] = 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) # Legacy models (keep for backward compatibility during transition)

View file

@ -69,7 +69,14 @@ window.app = Vue.createApp({
port: 5432, port: 5432,
database_name: '', database_name: '',
username: '', 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, port: this.configDialog.data.port,
database_name: this.configDialog.data.database_name, database_name: this.configDialog.data.database_name,
username: this.configDialog.data.username, 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( const {data: config} = await LNbits.api.request(
@ -178,7 +192,14 @@ window.app = Vue.createApp({
port: 5432, port: 5432,
database_name: '', database_name: '',
username: '', 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: { 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() { clientOptions() {
return this.dcaClients.map(client => ({ return this.dcaClients.map(client => ({
label: `${client.user_id.substring(0, 8)}... (${client.dca_mode})`, label: `${client.user_id.substring(0, 8)}... (${client.dca_mode})`,

View file

@ -558,6 +558,83 @@
hint="Database password" hint="Database password"
></q-input> ></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"> <q-banner v-if="!configDialog.data.id" class="bg-blue-1 text-blue-9">
<template v-slot:avatar> <template v-slot:avatar>
<q-icon name="info" color="blue" /> <q-icon name="info" color="blue" />
@ -571,10 +648,11 @@
unelevated unelevated
color="primary" color="primary"
type="submit" type="submit"
:disable="!configDialog.data.host || !configDialog.data.database_name || !configDialog.data.username" :disable="!isConfigFormValid"
@click.stop
>Save Configuration</q-btn >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 >Cancel</q-btn
> >
</div> </div>

View file

@ -5,6 +5,17 @@ import asyncpg
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from loguru import logger 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.core.services import create_invoice, pay_invoice
from lnbits.settings import settings from lnbits.settings import settings
@ -26,6 +37,7 @@ class LamassuTransactionProcessor:
def __init__(self): def __init__(self):
self.last_check_time = None self.last_check_time = None
self.processed_transaction_ids = set() self.processed_transaction_ids = set()
self.ssh_tunnel = None
async def get_db_config(self) -> Optional[Dict[str, Any]]: async def get_db_config(self) -> Optional[Dict[str, Any]]:
"""Get database configuration from the database""" """Get database configuration from the database"""
@ -41,12 +53,78 @@ class LamassuTransactionProcessor:
"database": config.database_name, "database": config.database_name,
"user": config.username, "user": config.username,
"password": config.password, "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: except Exception as e:
logger.error(f"Error getting database configuration: {e}") logger.error(f"Error getting database configuration: {e}")
return None 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]: async def connect_to_lamassu_db(self) -> Optional[asyncpg.Connection]:
"""Establish connection to Lamassu Postgres database""" """Establish connection to Lamassu Postgres database"""
try: try:
@ -54,12 +132,17 @@ class LamassuTransactionProcessor:
if not db_config: if not db_config:
return None 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( connection = await asyncpg.connect(
host=db_config["host"], host=connection_config["host"],
port=db_config["port"], port=connection_config["port"],
database=db_config["database"], database=connection_config["database"],
user=db_config["user"], user=connection_config["user"],
password=db_config["password"], password=connection_config["password"],
timeout=30 timeout=30
) )
logger.info("Successfully connected to Lamassu database") logger.info("Successfully connected to Lamassu database")
@ -286,9 +369,13 @@ class LamassuTransactionProcessor:
finally: finally:
await connection.close() await connection.close()
# Close SSH tunnel if it was used
self.close_ssh_tunnel()
except Exception as e: except Exception as e:
logger.error(f"Error in polling cycle: {e}") logger.error(f"Error in polling cycle: {e}")
# Ensure cleanup on error
self.close_ssh_tunnel()
# Global processor instance # Global processor instance