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:
parent
1f7999a556
commit
8f046ad0c5
6 changed files with 287 additions and 14 deletions
14
crud.py
14
crud.py
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
|
||||||
21
models.py
21
models.py
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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})`,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue