Improve stability (#25)

* chore: increase log level

* feat: secure relays endpoint

* feat: add config for the extension

* chore: update `min_lnbits_version`

* chore: improve logging

* fix: decrypt logic

* chore: improve logs
This commit is contained in:
Vlad Stan 2024-01-22 13:46:22 +02:00 committed by GitHub
parent 16ae9d15a1
commit a119c3836a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 220 additions and 193 deletions

View file

@ -2,6 +2,6 @@
"name": "Nostr Client",
"short_description": "Nostr client for extensions",
"tile": "/nostrclient/static/images/nostr-bitcoin.png",
"contributors": ["calle"],
"min_lnbits_version": "0.11.0"
"contributors": ["calle", "motorina0"],
"min_lnbits_version": "0.12.0"
}

40
crud.py
View file

@ -1,7 +1,9 @@
from typing import List
from typing import List, Optional
import json
from . import db
from .models import Relay
from .models import Config, Relay
async def get_relays() -> List[Relay]:
@ -25,3 +27,37 @@ async def add_relay(relay: Relay) -> None:
async def delete_relay(relay: Relay) -> None:
await db.execute("DELETE FROM nostrclient.relays WHERE url = ?", (relay.url,))
######################CONFIG#######################
async def create_config() -> Config:
config = Config()
await db.execute(
"""
INSERT INTO nostrclient.config (json_data)
VALUES (?)
""",
(json.dumps(config.dict())),
)
row = await db.fetchone(
"SELECT json_data FROM nostrclient.config", ()
)
return json.loads(row[0], object_hook=lambda d: Config(**d))
async def update_config(config: Config) -> Optional[Config]:
await db.execute(
"""UPDATE nostrclient.config SET json_data = ?""",
(json.dumps(config.dict())),
)
row = await db.fetchone(
"SELECT json_data FROM nostrclient.config", ()
)
return json.loads(row[0], object_hook=lambda d: Config(**d))
async def get_config() -> Optional[Config]:
row = await db.fetchone(
"SELECT json_data FROM nostrclient.config", ()
)
return json.loads(row[0], object_hook=lambda d: Config(**d)) if row else None

View file

@ -11,3 +11,15 @@ async def m001_initial(db):
);
"""
)
async def m002_create_config_table(db):
"""
Allow the extension to persist and retrieve any number of config values.
"""
await db.execute(
"""CREATE TABLE nostrclient.config (
json_data TEXT NOT NULL
);"""
)

View file

@ -42,3 +42,8 @@ class TestMessageResponse(BaseModel):
private_key: str
public_key: str
event_json: str
class Config(BaseModel):
private_ws: bool = True
public_ws: bool = False

View file

@ -72,6 +72,7 @@ class RelayManager:
def close_subscription(self, id: str):
try:
logger.info(f"Closing subscription: '{id}'.")
with self._subscriptions_lock:
if id in self._cached_subscriptions:
self._cached_subscriptions.pop(id)

View file

@ -42,7 +42,7 @@ class NostrRouter:
pass
try:
await self.websocket.close()
await self.websocket.close(reason="Websocket connection closed")
except Exception as _:
pass
@ -113,7 +113,7 @@ class NostrRouter:
def _handle_notices(self):
while len(NostrRouter.received_subscription_notices):
my_event = NostrRouter.received_subscription_notices.pop(0)
logger.info(f"[Relay '{my_event.url}'] Notice: '{my_event.content}']")
logger.debug(f"[Relay '{my_event.url}'] Notice: '{my_event.content}']")
# Note: we don't send it to the user because
# we don't know who should receive it
nostr_client.relay_manager.handle_notice(my_event)
@ -136,6 +136,7 @@ class NostrRouter:
def _handle_client_req(self, json_data):
subscription_id = json_data[1]
logger.info(f"New subscription: '{subscription_id}'")
subscription_id_rewritten = urlsafe_short_hash()
self.original_subscription_ids[subscription_id_rewritten] = subscription_id
filters = json_data[2:]
@ -154,5 +155,6 @@ class NostrRouter:
if subscription_id_rewritten:
self.original_subscription_ids.pop(subscription_id_rewritten)
nostr_client.relay_manager.close_subscription(subscription_id_rewritten)
logger.info(f"Unsubscribe from '{subscription_id_rewritten}'. Original id: '{subscription_id}.'")
else:
logger.debug(f"Failed to unsubscribe from '{subscription_id}.'")
logger.info(f"Failed to unsubscribe from '{subscription_id}.'")

View file

@ -4,38 +4,24 @@
<div class="col-12 col-md-7 q-gutter-y-md">
<q-card>
<q-form @submit="addRelay">
<div class="row q-pa-md">
<div class="col-9">
<q-input
outlined
v-model="relayToAdd"
dense
filled
label="Relay URL"
></q-input>
<div class="row">
<div class="col-12 col-md-7 q-pa-md">
<q-input outlined v-model="relayToAdd" dense filled label="Relay URL"></q-input>
</div>
<div class="col-3">
<q-btn-dropdown
unelevated
split
color="primary"
class="float-right"
type="submit"
label="Add Relay X"
>
<q-item
v-for="relay in predefinedRelays"
:key="relay"
@click="addCustomRelay(relay)"
clickable
v-close-popup
>
<div class="col-6 col-md-3 q-pa-md">
<q-btn-dropdown unelevated split color="primary" class="float-left" type="submit" label="Add Relay Y">
<q-item v-for="relay in predefinedRelays" :key="relay" @click="addCustomRelay(relay)" clickable
v-close-popup>
<q-item-section>
<q-item-label><span v-text="relay"></span></q-item-label>
</q-item-section>
</q-item>
</q-btn-dropdown>
</div>
<div class="col-6 col-md-2 q-pa-md">
<q-btn unelevated @click="config.showDialog = true" color="primary" icon="settings"
class="float-right"></q-btn>
</div>
</div>
</q-form>
</q-card>
@ -46,36 +32,18 @@
<h5 class="text-subtitle1 q-my-none">Nostrclient</h5>
</div>
<div class="col-auto">
<q-input
borderless
dense
debounce="300"
v-model="filter"
placeholder="Search"
>
<q-input borderless dense debounce="300" v-model="filter" placeholder="Search">
<template v-slot:append>
<q-icon name="search"></q-icon>
</template>
</q-input>
</div>
</div>
<q-table
flat
dense
:data="nostrrelayLinks"
row-key="id"
:columns="relayTable.columns"
:pagination.sync="relayTable.pagination"
:filter="filter"
>
<q-table flat dense :data="nostrrelayLinks" row-key="id" :columns="relayTable.columns"
:pagination.sync="relayTable.pagination" :filter="filter">
<template v-slot:header="props">
<q-tr :props="props">
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<q-th v-for="col in props.cols" :key="col.name" :props="props" auto-width>
<div v-if="col.name == 'id'"></div>
<div v-else>{{ col.label }}</div>
</q-th>
@ -84,12 +52,7 @@
<template v-slot:body="props">
<q-tr :props="props">
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
auto-width
>
<q-td v-for="col in props.cols" :key="col.name" :props="props" auto-width>
<div v-if="col.name == 'connected'">
<div v-if="col.value">🟢</div>
<div v-else>🔴</div>
@ -98,29 +61,17 @@
<div>
⬆️ <span v-text="col.value.sentEvents"></span> ⬇️
<span v-text="col.value.receveidEvents"></span>
<span
@click="showLogDataDialog(col.value.errorList)"
class="cursor-pointer"
>
<span @click="showLogDataDialog(col.value.errorList)" class="cursor-pointer">
⚠️ <span v-text="col.value.errorCount"> </span>
</span>
<span
@click="showLogDataDialog(col.value.noticeList)"
class="cursor-pointer float-right"
>
<span @click="showLogDataDialog(col.value.noticeList)" class="cursor-pointer float-right">
</span>
</div>
</div>
<div v-else-if="col.name == 'delete'">
<q-btn
flat
dense
size="md"
@click="showDeleteRelayDialog(props.row.url)"
icon="cancel"
color="pink"
></q-btn>
<q-btn flat dense size="md" @click="showDeleteRelayDialog(props.row.url)" icon="cancel"
color="pink"></q-btn>
</div>
<div v-else>
<div>{{ col.value }}</div>
@ -136,32 +87,15 @@
<div class="row">
<div class="col">
<div class="text-weight-bold">
<q-btn
flat
dense
size="0.6rem"
class="q-px-none q-mx-none"
color="grey"
icon="content_copy"
@click="copyText(`wss://${host}/nostrclient/api/v1/relay`)"
><q-tooltip>Copy address</q-tooltip></q-btn
>
<q-btn flat dense size="0.6rem" class="q-px-none q-mx-none" color="grey" icon="content_copy"
@click="copyText(`wss://${host}/nostrclient/api/v1/relay`)"><q-tooltip>Copy address</q-tooltip></q-btn>
Your endpoint:
<q-badge
outline
class="q-ml-sm text-subtitle2"
:label="`wss://${host}/nostrclient/api/v1/relay`"
/>
<q-badge outline class="q-ml-sm text-subtitle2" :label="`wss://${host}/nostrclient/api/v1/relay`" />
</div>
</div>
</div>
</q-card-section>
<q-expansion-item
group="advanced"
icon="settings"
label="Test this endpoint"
@click="toggleTestPanel"
>
<q-expansion-item group="advanced" icon="settings" label="Test this endpoint" @click="toggleTestPanel">
<q-separator></q-separator>
<q-card-section>
<div class="row">
@ -169,13 +103,8 @@
<span>Sender Private Key:</span>
</div>
<div class="col-9">
<q-input
outlined
v-model="testData.senderPrivateKey"
dense
filled
label="Private Key (optional)"
></q-input>
<q-input outlined v-model="testData.senderPrivateKey" dense filled
label="Private Key (optional)"></q-input>
</div>
</div>
<div class="row q-mt-sm q-mb-lg">
@ -184,8 +113,7 @@
<q-badge color="yellow" text-color="black">
<span>
No not use your real private key! Leave empty for a randomly
generated key.</span
>
generated key.</span>
</q-badge>
</div>
</div>
@ -194,13 +122,7 @@
<span>Sender Public Key:</span>
</div>
<div class="col-9">
<q-input
outlined
v-model="testData.senderPublicKey"
dense
readonly
filled
></q-input>
<q-input outlined v-model="testData.senderPublicKey" dense readonly filled></q-input>
</div>
</div>
<div class="row q-mt-md">
@ -208,15 +130,8 @@
<span>Test Message:</span>
</div>
<div class="col-9">
<q-input
outlined
v-model="testData.message"
dense
filled
rows="3"
type="textarea"
label="Test Message *"
></q-input>
<q-input outlined v-model="testData.message" dense filled rows="3" type="textarea"
label="Test Message *"></q-input>
</div>
</div>
<div class="row q-mt-md">
@ -224,35 +139,22 @@
<span>Receiver Public Key:</span>
</div>
<div class="col-9">
<q-input
outlined
v-model="testData.recieverPublicKey"
dense
filled
label="Public Key (hex or npub) *"
></q-input>
<q-input outlined v-model="testData.recieverPublicKey" dense filled
label="Public Key (hex or npub) *"></q-input>
</div>
</div>
<div class="row q-mt-sm q-mb-lg">
<div class="col-3"></div>
<div class="col-9">
<q-badge color="yellow" text-color="black">
<span
>This is the recipient of the message. Field required.</span
>
<span>This is the recipient of the message. Field required.</span>
</q-badge>
</div>
</div>
<div class="row">
<div class="col-12">
<q-btn
:disabled="!testData.recieverPublicKey || !testData.message"
@click="sendTestMessage"
unelevated
color="primary"
class="float-right"
>Send Message</q-btn
>
<q-btn :disabled="!testData.recieverPublicKey || !testData.message" @click="sendTestMessage" unelevated
color="primary" class="float-right">Send Message</q-btn>
</div>
</div>
</q-card-section>
@ -264,14 +166,7 @@
<span>Sent Data:</span>
</div>
<div class="col-9">
<q-input
outlined
v-model="testData.sentData"
dense
filled
rows="5"
type="textarea"
></q-input>
<q-input outlined v-model="testData.sentData" dense filled rows="5" type="textarea"></q-input>
</div>
</div>
<div class="row q-mt-md">
@ -279,14 +174,7 @@
<span>Received Data:</span>
</div>
<div class="col-9">
<q-input
outlined
v-model="testData.receivedData"
dense
filled
rows="5"
type="textarea"
></q-input>
<q-input outlined v-model="testData.receivedData" dense filled rows="5" type="textarea"></q-input>
</div>
</div>
</q-card-section>
@ -305,12 +193,8 @@
</p>
<p>
<q-badge
outline
class="q-ml-sm text-subtitle2"
color="primary"
:label="`wss://${host}/nostrclient/api/v1/relay`"
/>
<q-badge outline class="q-ml-sm text-subtitle2" color="primary"
:label="`wss://${host}/nostrclient/api/v1/relay`" />
</p>
Only Admin users can manage this extension.
<q-card-section></q-card-section>
@ -320,21 +204,27 @@
<q-dialog v-model="logData.show" position="top">
<q-card class="q-pa-lg q-pt-xl">
<q-input
filled
dense
v-model.trim="logData.data"
type="textarea"
rows="25"
cols="200"
label="Log Data"
></q-input>
<q-input filled dense v-model.trim="logData.data" type="textarea" rows="25" cols="200" label="Log Data"></q-input>
<div class="row q-mt-lg">
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Close</q-btn>
</div>
</q-card>
</q-dialog>
<q-dialog v-model="config.showDialog" position="top">
<q-card class="q-pa-lg q-pt-xl lnbits__dialog-card">
<q-form @submit="updateConfig" class="q-gutter-md">
<q-toggle label="Expose Private Websocket" color="secodary" v-model="config.data.private_ws"></q-toggle>
<br />
<q-toggle label="Expose Public Websocket" color="secodary" v-model="config.data.public_ws"></q-toggle>
<div class="row q-mt-lg">
<q-btn unelevated color="primary" type="submit">Update</q-btn>
<q-btn v-close-popup flat color="grey" class="q-ml-auto">Cancel</q-btn>
</div>
</q-form>
</q-card>
</q-dialog>
</div>
{% endraw %} {% endblock %} {% block scripts %} {{ window_vars(user) }}
@ -380,6 +270,10 @@
show: false,
data: null
},
config: {
showDialog: false,
data: {},
},
testData: {
show: false,
wsConnection: null,
@ -477,7 +371,7 @@
'POST',
'/nostrclient/api/v1/relay?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey,
{url: this.relayToAdd}
{ url: this.relayToAdd }
)
.then(function (response) {
console.log('response:', response)
@ -509,7 +403,7 @@
'DELETE',
'/nostrclient/api/v1/relay?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey,
{url: url}
{ url: url }
)
.then(response => {
const relayIndex = this.nostrrelayLinks.indexOf(r => r.url === url)
@ -522,6 +416,34 @@
LNbits.utils.notifyApiError(error)
})
},
getConfig: async function () {
try {
const { data } = await LNbits.api
.request(
'GET',
'/nostrclient/api/v1/config',
this.g.user.wallets[0].adminkey
)
this.config.data = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
},
updateConfig: async function () {
try {
const { data } = await LNbits.api.request(
'PUT',
'/nostrclient/api/v1/config',
this.g.user.wallets[0].adminkey,
this.config.data
)
this.config.data = data
} catch (error) {
LNbits.utils.notifyApiError(error)
}
this.config.showDialog = false
},
toggleTestPanel: async function () {
if (this.testData.show) {
await this.hideTestPannel()
@ -559,7 +481,7 @@
},
sendTestMessage: async function () {
try {
const {data} = await LNbits.api.request(
const { data } = await LNbits.api.request(
'PUT',
'/nostrclient/api/v1/relay/test?usr=' + this.g.user.id,
this.g.user.wallets[0].adminkey,
@ -580,7 +502,7 @@
const subscription = JSON.stringify([
'REQ',
'test-dms',
{kinds: [4], '#p': [event.pubkey]}
{ kinds: [4], '#p': [event.pubkey] }
])
this.testData.wsConnection.send(subscription)
} catch (error) {
@ -642,9 +564,9 @@
},
sleep: ms => new Promise(r => setTimeout(r, ms))
},
created: function () {
var self = this
created: async function () {
this.getRelays()
await this.getConfig()
setInterval(this.getRelays, 5000)
}
})

View file

@ -7,12 +7,12 @@ from loguru import logger
from starlette.exceptions import HTTPException
from lnbits.decorators import check_admin
from lnbits.helpers import urlsafe_short_hash
from lnbits.helpers import decrypt_internal_message, urlsafe_short_hash
from . import nostr_client, nostrclient_ext, scheduled_tasks
from .crud import add_relay, delete_relay, get_relays
from .crud import add_relay, create_config, delete_relay, get_config, get_relays, update_config
from .helpers import normalize_public_key
from .models import Relay, TestMessage, TestMessageResponse
from .models import Config, Relay, TestMessage, TestMessageResponse
from .nostr.key import EncryptedDirectMessage, PrivateKey
from .router import NostrRouter
@ -20,7 +20,7 @@ from .router import NostrRouter
all_routers: list[NostrRouter] = []
@nostrclient_ext.get("/api/v1/relays")
@nostrclient_ext.get("/api/v1/relays", dependencies=[Depends(check_admin)])
async def api_get_relays() -> List[Relay]:
relays = []
for url, r in nostr_client.relay_manager.relays.items():
@ -133,19 +133,68 @@ async def api_stop():
return {"success": True}
@nostrclient_ext.websocket("/api/v1/relay")
async def ws_relay(websocket: WebSocket) -> None:
@nostrclient_ext.websocket("/api/v1/{id}")
async def ws_relay(id: str, websocket: WebSocket) -> None:
"""Relay multiplexer: one client (per endpoint) <-> multiple relays"""
await websocket.accept()
router = NostrRouter(websocket)
router.start()
all_routers.append(router)
# we kill this websocket and the subscriptions
# if the user disconnects and thus `connected==False`
while router.connected:
await asyncio.sleep(10)
logger.info("New websocket connection at: '/api/v1/relay'")
try:
config = await get_config()
await router.stop()
all_routers.remove(router)
if not config.private_ws and not config.public_ws:
raise ValueError("Websocket connections not accepted.")
if id == "relay":
if not config.public_ws:
raise ValueError("Public websocket connections not accepted.")
else:
if not config.private_ws:
raise ValueError("Private websocket connections not accepted.")
if decrypt_internal_message(id) != "relay":
raise ValueError("Invalid websocket endpoint.")
await websocket.accept()
router = NostrRouter(websocket)
router.start()
all_routers.append(router)
# we kill this websocket and the subscriptions
# if the user disconnects and thus `connected==False`
while router.connected:
await asyncio.sleep(10)
try:
await router.stop()
except Exception as e:
logger.debug(e)
all_routers.remove(router)
logger.info("Closed websocket connection at: '/api/v1/relay'")
except ValueError as ex:
logger.warning(ex)
await websocket.close(reason=str(ex))
except Exception as ex:
logger.warning(ex)
await websocket.close(reason="Websocket connection unexpected closed")
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot accept websocket connection",
)
@nostrclient_ext.get("/api/v1/config", dependencies=[Depends(check_admin)])
async def api_get_relays() -> Config:
config = await get_config()
if not config:
await create_config()
return config
@nostrclient_ext.put("/api/v1/config", dependencies=[Depends(check_admin)])
async def api_update_config(
data: Config
):
config = await update_config(data)
assert config
return config.dict()