Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Grpc web proxy client example #553

Draft
wants to merge 6 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,12 @@ docs/.cache

# Don't store poetry.lock file
libs/cln-version-manager/poetry.lock

# Ignore files from Shahana's testing
.env
metadata.json
.gltestserver
uv.lock
examples/javascript/node_modules
examples/javascript/response.bin
examples/javascript/package-lock.json
6 changes: 4 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,12 @@ docs-publish: docs

gltestserver-image: docker/gl-testserver/Dockerfile
docker build \
--push \
--build-arg DOCKER_USER=$(shell whoami) \
--build-arg UID=$(shell id -u) \
--build-arg GID=$(shell id -g) \
-t gltestserver \
--build-arg REPO_PATH=$(shell git rev-parse --show-toplevel) \
-t shahanafarooqui/gltestserver \
-f docker/gl-testserver/Dockerfile \
.

Expand All @@ -208,5 +210,5 @@ gltestserver: gltestserver-image
-e DOCKER_USER=$(shell whoami) \
--net=host \
-ti \
-v $(shell pwd)/.testserver:/tmp/gltestserver \
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Notice that with this you cannot run the test server outside of the test servers code itself, as it mounts its own code into the container as a volume. The goal here was to create a small shared island inside and outside, but otherwise leave the host directly untouched. This allows for users to use the test server for example from inside their projects repository, allowing them to test against GL.

-v $(shell pwd)/:$(shell pwd) \
gltestserver
38 changes: 26 additions & 12 deletions docker/gl-testserver/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
FROM ubuntu:22.04 AS python-builder

ARG BITCOIN_VERSION=24.0
ARG GID=0
ARG UID=0
ARG DOCKER_USER=dev
ARG REPO_PATH=/repo

ENV RUST_VERSION=1.74
ENV PATH=$CARGO_HOME/bin:$PATH
ENV PROTOC_VERSION=3.19.3
ENV CFSSL_VERSION=1.6.5
ENV GL_TESTING_IGNORE_HASH=true
ARG BITCOIN_VERSION=24.0
ARG GID=0
ARG UID=0
ARG DOCKER_USER=dev
ENV PATH=$PATH:/home/$DOCKER_USER/.local/bin/:/opt/bitcoin/bin:/home/$DOCKER_USER/.cargo/bin
#ENV VIRTUAL_ENV=/tmp/venv
ENV CARGO_TARGET_DIR=/tmp/cargo
ENV REPO=$REPO_PATH

# Force this target dir, so the scripts can find all the binaries.
#ENV CARGO_TARGET_DIR=${REPO}/target
ENV CARGO_TARGET_DIR=/tmp/target/
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This no longer deposita the artifacts in the shared island, and thus the host cannot access them after the container built them.


RUN apt update && apt install -qqy \
curl \
Expand Down Expand Up @@ -50,9 +55,15 @@ RUN cd /tmp/ && \
mv /tmp/bitcoin-$BITCOIN_VERSION/ /opt/bitcoin && \
rm -rf bitcoin.tar.gz /tmp/bitcoin-$BITCOIN_VERSION

ADD ../ /repo/libs
RUN chown $DOCKER_USER:users -R /repo
RUN mkdir -p ${REPO_PATH} && \
chown $DOCKER_USER:users ${REPO_PATH}

ADD ../../libs/ ${REPO_PATH}/libs
ADD ../../pyproject.toml ${REPO_PATH}/

ADD ../../ ${REPO_PATH}/
RUN chown $DOCKER_USER:users -R ${REPO_PATH}
RUN chown $DOCKER_USER:users -R /home/$DOCKER_USER
USER $DOCKER_USER

RUN curl \
Expand All @@ -62,10 +73,13 @@ RUN curl \
-s -- -y --default-toolchain ${RUST_VERSION}
RUN rustup default stable

WORKDIR ${REPO_PATH}/libs/gl-testserver/

RUN cargo build --bin gl-plugin
RUN cargo build --bin gl-signerproxy

RUN curl -LsSf https://astral.sh/uv/install.sh | sh

WORKDIR /repo/libs/gl-testserver/
RUN echo $PATH
RUN uv sync --locked -v
RUN uv lock && uv sync --locked -v
RUN uv run clnvm get-all
CMD uv run gltestserver run --metadata /tmp/gltestserver
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, I must've missed this one, as it also doesn't point to the shared island 👍

CMD uv run gltestserver run --metadata ${REPO}/ --directory ${REPO}/.gltestserver
133 changes: 133 additions & 0 deletions examples/javascript/grpc-web-proxy-client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
const path = require('path');
const axios = require('axios');
const protobuf = require('protobufjs');

const PORT = process.argv[2] || '1111';
const AUTH_PUBKEY = 'AqqultE98ITS3BQRsNzuctWdYqeX87TRbY4tRe8NmllJ';
const AUTH_SIGNATURE = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const PROTO_PATHS = [
path.join(process.cwd(), '../../libs/gl-client/.resources/proto/node.proto'),
path.join(process.cwd(), '../../libs/gl-client/.resources/proto/primitives.proto')
];

function getGrpcErrorMessage(grpcStatusCode) {
const grpcStatusMessages = {
0: 'OK: The operation completed successfully.',
1: 'CANCELLED: The operation was cancelled (typically by the caller).',
2: 'UNKNOWN: Unknown error. Usually means an internal error occurred.',
3: 'INVALID_ARGUMENT: The client specified an invalid argument.',
4: 'DEADLINE_EXCEEDED: The operation took too long and exceeded the time limit.',
5: 'NOT_FOUND: A specified resource was not found.',
6: 'ALREADY_EXISTS: The resource already exists.',
7: 'PERMISSION_DENIED: The caller does not have permission to execute the operation.',
8: 'RESOURCE_EXHAUSTED: A resource (such as quota) was exhausted.',
9: 'FAILED_PRECONDITION: The operation was rejected due to a failed precondition.',
10: 'ABORTED: The operation was aborted, typically due to a concurrency issue.',
11: 'OUT_OF_RANGE: The operation attempted to access an out-of-range value.',
12: 'UNIMPLEMENTED: The operation is not implemented or supported by the server.',
13: 'INTERNAL: Internal server error.',
14: 'UNAVAILABLE: The service is unavailable (e.g., network issues, server down).',
15: 'DATA_LOSS: Unrecoverable data loss or corruption.',
16: 'UNAUTHENTICATED: The request is missing or has invalid authentication credentials.'
}
return grpcStatusMessages[grpcStatusCode] || "UNKNOWN_STATUS_CODE: The status code returned by gRPC server is not in the list.";
}

async function encodePayload(clnNode, method, payload) {
const methodRequest = clnNode.lookupType(`cln.${method}Request`);
const errMsg = methodRequest.verify(payload);
if (errMsg) throw new Error(errMsg);
const header = Buffer.alloc(4);
header.writeUInt8(0, 0);
const requestPayload = methodRequest.create(payload);
const encodedPayload = methodRequest.encodeDelimited(requestPayload).finish();
return Buffer.concat([header, encodedPayload]);
}

async function sendRequest(methodUrl, encodedPayload) {
const buffer = Buffer.alloc(8);
buffer.writeUInt32BE(Math.floor(Date.now() / 1000), 4);
const axiosConfig = {
responseType: 'arraybuffer',
headers: {
'content-type': 'application/grpc',
'accept': 'application/grpc',
'glauthpubkey': AUTH_PUBKEY,
'glauthsig': AUTH_SIGNATURE,
'glts': buffer.toString('base64'),
},
};
return await axios.post(`http://localhost:${PORT}/cln.Node/${methodUrl}`, encodedPayload, axiosConfig);
}

function transformValue(key, value) {
if ((value.type && value.type === "Buffer") || value instanceof Buffer || value instanceof Uint8Array) {
return Buffer.from(value).toString('hex');
}
if (value.msat && !Number.isNaN(parseInt(value.msat))) {
// FIXME: Amount.varify check will work with 0 NOT '0'. Amount default is '0'.
return parseInt(value.msat);
}
return value;
}

function decodeResponse(clnNode, method, response) {
const methodResponse = clnNode.lookupType(`cln.${method}Response`)
const offset = 5;
const responseData = new Uint8Array(response.data).slice(offset);
const grpcStatus = +response.headers['grpc-status'];
if (grpcStatus !== 200) {
let errorDecoded = new TextDecoder("utf-8").decode(responseData);
if (errorDecoded !== 'None') {
errorDecoded = JSON.parse(errorDecoded.replace(/([a-zA-Z0-9_]+):/g, '"$1":'));
} else {
errorDecoded = {code: grpcStatus, message: getGrpcErrorMessage(grpcStatus)};
}
return { grpc_code: grpcStatus, grpc_error: getGrpcErrorMessage(grpcStatus), error: errorDecoded};
} else {
// FIXME: Use decodeDelimited
const decodedRes = methodResponse.decode(responseData);
const decodedResObject = methodResponse.toObject(decodedRes, {
longs: String,
enums: String,
bytes: Buffer,
defaults: true,
arrays: true,
objects: true,
});
return JSON.parse(JSON.stringify(decodedResObject, transformValue));
}
}

async function fetchNodeData() {
try {
const clnNode = new protobuf.Root().loadSync(PROTO_PATHS, { keepCase: true });
const FeeratesStyle = clnNode.lookupEnum('cln.FeeratesStyle');
const NewaddrAddresstype = clnNode.lookupEnum('cln.NewaddrAddresstype');
const methods = ['Getinfo', 'Feerates', 'Newaddr', 'Invoice', 'Listinvoices'];
const method_payloads = [{}, {style: FeeratesStyle.values.PERKW}, {addresstype: NewaddrAddresstype.values.ALL}, {amount_msat: {amount: {msat: 500000}}, description: 'My first coffee invoice', label: 'firstcoffeeinv'}, {}];
for (let i = 0; i < methods.length; i++) {
console.log('--------------------------------------------\n', (i + 1), '-', methods[i], '\n--------------------------------------------');
console.log('Raw Payload:\n', method_payloads[i]);
const encodedPayload = await encodePayload(clnNode, methods[i], method_payloads[i]);
console.log('\nEncoded Payload:\n', encodedPayload);
try {
const response = await sendRequest(methods[i], encodedPayload);
console.log('\nRaw Response:\nHeaders: ', response.headers, '\nData: ', response.data);
const responseJSON = decodeResponse(clnNode, methods[i], response);
console.log('\nDecoded Response:');
console.dir(responseJSON, { depth: null, color: true });
} catch (error) {
console.error('\nERROR:\n', error.response.status, ' - ', error.response.statusText);
}
}
} catch (error) {
console.error('Error:', error.message);
if (error.response) {
console.error('Response status:', error.response.status);
console.error('Response data:', error.response.data);
}
}
}

fetchNodeData();
19 changes: 19 additions & 0 deletions examples/javascript/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "grpc-web-proxy-client",
"version": "1.0.0",
"description": "Example for grpc web proxy client",
"main": "grpc-web-proxy-client.js",
"directories": {
"doc": "doc",
"test": "tests"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^1.7.9",
"protobufjs": "^7.4.0"
}
}
1 change: 1 addition & 0 deletions libs/gl-client-py/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
[project]
name = "gl-client"
version = "0.3.0"

dependencies = [
"protobuf>=3",
Expand Down
71 changes: 19 additions & 52 deletions libs/gl-testing/gltesting/certs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import tempfile
import json
import os
from sh import cfssl, openssl, cfssljson
import subprocess
from cryptography import x509
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat._oid import NameOID
Expand Down Expand Up @@ -108,9 +108,13 @@ def path_to_identity(path):
)

def postprocess_private_key(keyfile):
converted = openssl("pkcs8", "-topk8", "-nocrypt", "-in", keyfile).stdout
with open(keyfile, "wb") as f:
f.write(converted)
result = subprocess.run(["openssl", "pkcs8", "-topk8", "-nocrypt", "-in", keyfile], capture_output=True, text=True)
if result.returncode == 0:
converted = result.stdout
with open(keyfile, "wb") as f:
f.write(converted.encode())
else:
raise RuntimeError(f"OpenSSL command failed with error: {result.stderr}")


def parent_ca(path):
Expand Down Expand Up @@ -167,24 +171,15 @@ def genca(idpath):
if not os.path.exists(directory):
os.makedirs(directory)

cfssljson(cfssl("gencert", "-initca", tmpcsr.name), "-bare", path[3])

certs_json = subprocess.check_output(["cfssl", "gencert", "-initca", tmpcsr.name])
subprocess.run(["cfssljson", "-bare", path[3]], input=certs_json)

# Write config
tmpconfig = tempfile.NamedTemporaryFile(mode="w")
tmpconfig.write(config)
tmpconfig.flush()
cfssljson(
cfssl(
"sign",
f"-ca={parent[0]}",
f"-ca-key={parent[1]}",
f"-config={tmpconfig.name}",
f"-profile={profile}",
path[3] + ".csr",
),
"-bare",
path[3],
)
sign_certs_json = subprocess.check_output(["cfssl", "sign", f"-ca={parent[0]}", f"-ca-key={parent[1]}", f"-config={tmpconfig.name}", f"-profile={profile}", path[3] + ".csr"])
subprocess.run(["cfssljson", "-bare", path[3]], input=sign_certs_json)
# Cleanup the temporary certificate signature request
os.remove(path[3] + ".csr")

Expand Down Expand Up @@ -225,18 +220,8 @@ def gencert(idpath):
if not os.path.exists(directory):
os.makedirs(directory)

cfssljson(
cfssl(
"gencert",
f"-ca={parent[0]}",
f"-ca-key={parent[1]}",
f"-config={tmpconfig.name}",
f"-profile={profile}",
tmpcsr.name,
),
"-bare",
path[3],
)
certs_json = subprocess.check_output(["cfssl", "gencert", f"-ca={parent[0]}", f"-ca-key={parent[1]}", f"-config={tmpconfig.name}", f"-profile={profile}", tmpcsr.name])
subprocess.run(["cfssljson", "-bare", path[3]], input=certs_json)
# Cleanup the temporary certificate signature request
os.remove(path[3] + ".csr")

Expand Down Expand Up @@ -300,28 +285,11 @@ def gencert_from_csr(csr: bytes, recover=False, pairing=False):
os.makedirs(directory)

if pairing:
cfssljson(
cfssl(
"sign",
f"-ca={parent[0]}",
f"-ca-key={parent[1]}",
tmpcsr.name,
tmpsubject.name,
),
"-bare",
path[3],
)
sign_certs_json = subprocess.check_output(["cfssl", "sign", f"-ca={parent[0]}", f"-ca-key={parent[1]}", tmpcsr.name, tmpsubject.name])
else:
cfssljson(
cfssl(
"sign",
f"-ca={parent[0]}",
f"-ca-key={parent[1]}",
tmpcsr.name,
),
"-bare",
path[3],
)
sign_certs_json = subprocess.check_output(["cfssl", "sign", f"-ca={parent[0]}", f"-ca-key={parent[1]}", tmpcsr.name])

subprocess.run(["cfssljson", "-bare", path[3]], input=sign_certs_json)

# Cleanup the temporary certificate signature request
os.remove(path[3] + ".csr")
Expand All @@ -333,4 +301,3 @@ def gencert_from_csr(csr: bytes, recover=False, pairing=False):
cert = certf.read()
certf.close()
return cert

Loading
Loading