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

Add extra configuration options to gRPC #7672

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
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
13 changes: 12 additions & 1 deletion doc/getting-started/getting-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,9 +235,20 @@ The [`lightning-listconfigs`](ref:lightning-listconfigs) command will output a v
If you have an unencrypted `hsm_secret` you want to encrypt on-disk, or vice versa,
see [`lightning-hsmtool`](ref:lightning-hsmtool).

- **grpc-scheme**=_scheme_ [plugin `cln-grpc`]

The scheme on which the gRPC plugin will listen for incoming connections. The default is `https`.
The interface supports both `http` and `https`. However, `http` can only be used if `grpc-host`
is set to a loopback address which is `127.0.0.1` for IPv4.

- **grpc-host**=_HOST_ [plugin `cln-grpc`]

The IP address for the gRPC plugin to listen for incoming connections;
The default is the IPv4 loopback address `127.0.0.1`.

- **grpc-port**=_portnum_ [plugin `cln-grpc`]

The port number for the GRPC plugin to listen for incoming connections; default is not to activate the plugin at all.
The port number for the GRPC plugin to listen for incoming connections. Default is 9736.

### Lightning node customization options

Expand Down
12 changes: 9 additions & 3 deletions doc/lightningd-config.5.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,15 +310,21 @@ If there is no `hsm_secret` yet, `lightningd` will create a new encrypted secret
If you have an unencrypted `hsm_secret` you want to encrypt on-disk, or vice versa,
see lightning-hsmtool(8).

* **grpc-scheme**=*scheme* [plugin `cln-grpc`]

The scheme on which the gRPC plugin will listen for incoming connections. The
default is `https`. The interface supports both `http` and `https`.
However, `http` can only be used if `grpc-host` is set to a loopback address
which is `127.0.0.1` for IPv4.

* **grpc-host**=*HOST* [plugin `cln-grpc`]

Defines the GRPC server host. Default is 127.0.0.1.
The IP address for the gRPC plugin to listen for incoming connections;
The default is the IPv4 loopback address `127.0.0.1`.

* **grpc-port**=*portnum* [plugin `cln-grpc`]

The port number for the GRPC plugin to listen for incoming
connections. Default is 9736.
The port number for the GRPC plugin to listen for incoming connections. Default is 9736.

* **grpc-msg-buffer-size**=*number* [plugin `cln-grpc`]

Expand Down
83 changes: 56 additions & 27 deletions plugins/grpc-plugin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,33 @@ use cln_grpc::pb::node_server::NodeServer;
use cln_plugin::{options, Builder, Plugin};
use cln_rpc::notifications::Notification;
use log::{debug, warn};
use std::net::SocketAddr;
use router::{GrpcRouterConfig, GrpcRouterScheme};
use std::path::PathBuf;
use tokio::sync::broadcast;
use tonic::transport::ServerTlsConfig;

mod router;
mod tls;

#[derive(Clone, Debug)]
struct PluginState {
rpc_path: PathBuf,
identity: tls::Identity,
ca_cert: Vec<u8>,
events: broadcast::Sender<cln_rpc::notifications::Notification>,
}

const OPTION_GRPC_PORT: options::DefaultIntegerConfigOption = options::ConfigOption::new_i64_with_default(
"grpc-port",
9736,
"Which port should the grpc plugin listen for incoming connections?"
);
const OPTION_GRPC_SCHEME: options::DefaultStringConfigOption =
options::ConfigOption::new_str_with_default(
"grpc-scheme",
"https",
"The scheme used by the gprc-plugin. Either 'http' or 'https'",
);

const OPTION_GRPC_PORT: options::DefaultIntegerConfigOption =
options::ConfigOption::new_i64_with_default(
"grpc-port",
9736,
"Which port should the grpc plugin listen for incoming connections?"
);

const OPTION_GRPC_HOST: options::DefaultStringConfigOption = options::ConfigOption::new_str_with_default(
"grpc-host",
Expand All @@ -38,9 +46,8 @@ const OPTION_GRPC_MSG_BUFFER_SIZE : options::DefaultIntegerConfigOption = option
async fn main() -> Result<()> {
debug!("Starting grpc plugin");

let directory = std::env::current_dir()?;

let plugin = match Builder::new(tokio::io::stdin(), tokio::io::stdout())
.option(OPTION_GRPC_SCHEME)
.option(OPTION_GRPC_PORT)
.option(OPTION_GRPC_HOST)
.option(OPTION_GRPC_MSG_BUFFER_SIZE)
Expand All @@ -61,8 +68,19 @@ async fn main() -> Result<()> {
None => return Ok(()),
};

let bind_port: i64 = plugin.option(&OPTION_GRPC_PORT).unwrap();
let bind_host: String = plugin.option(&OPTION_GRPC_HOST).unwrap();
let router_config = match GrpcRouterConfig::from_configured_plugin(&plugin) {
Ok(Some(cfg)) => cfg,
Ok(None) => {
log::info!("Running on default 'grpc-port' 9736.");
return Ok(());
}
Err(err) => {
log::warn!("{:?}", err);
plugin.disable(&format!("Invalid configuration: {:?}", err)).await?;
return Err(err)
}
};

let buffer_size: i64 = plugin.option(&OPTION_GRPC_MSG_BUFFER_SIZE).unwrap();
let buffer_size = match usize::try_from(buffer_size) {
Ok(b) => b,
Expand All @@ -76,57 +94,68 @@ async fn main() -> Result<()> {

let (sender, _) = broadcast::channel(buffer_size);

let (identity, ca_cert) = tls::init(&directory)?;

let state = PluginState {
rpc_path: PathBuf::from(plugin.configuration().rpc_file.as_str()),
identity,
ca_cert,
events: sender,
};

let plugin = plugin.start(state.clone()).await?;

let bind_addr: SocketAddr = format!("{}:{}", bind_host, bind_port).parse().unwrap();

tokio::select! {
_ = plugin.join() => {
// This will likely never be shown, if we got here our
// parent process is exiting and not processing out log
// messages anymore.
debug!("Plugin loop terminated")
}
e = run_interface(bind_addr, state) => {
e = run_interface(router_config, state) => {
warn!("Error running grpc interface: {:?}", e)
}
}
Ok(())
}

async fn run_interface(bind_addr: SocketAddr, state: PluginState) -> Result<()> {
let identity = state.identity.to_tonic_identity();
let ca_cert = tonic::transport::Certificate::from_pem(state.ca_cert);
fn create_server_tls_config() -> anyhow::Result<ServerTlsConfig> {
let directory = std::env::current_dir()?;
let (identity, ca_cert) = tls::init(&directory)?;

let identity = identity.to_tonic_identity();
let ca_cert = tonic::transport::Certificate::from_pem(ca_cert);

let tls = tonic::transport::ServerTlsConfig::new()
.identity(identity)
.client_ca_root(ca_cert);

let server = tonic::transport::Server::builder()
.tls_config(tls)
.context("configuring tls")?
return Ok(tls);
}

async fn run_interface(router_config: GrpcRouterConfig, state: PluginState) -> Result<()> {
let bind_addr = router_config.socket_addr();

let mut server = match router_config.scheme {
GrpcRouterScheme::HTTP => tonic::transport::Server::builder(),
GrpcRouterScheme::HTTPS => {
let server_tls_config = create_server_tls_config()?;
tonic::transport::Server::builder()
.tls_config(server_tls_config)
.context("Configuring tls")?
}
};

let svc_handle = server
.add_service(NodeServer::new(
cln_grpc::Server::new(&state.rpc_path, state.events.clone())
.await
.context("creating NodeServer instance")?,
))
.serve(bind_addr);
.serve(router_config.socket_addr());

debug!(
"Connecting to {:?} and serving grpc on {:?}",
&state.rpc_path, &bind_addr
);

server.await.context("serving requests")?;
svc_handle.await.context("serving requests")?;

Ok(())
}
Expand Down
81 changes: 81 additions & 0 deletions plugins/grpc-plugin/src/router.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
use std::net::{IpAddr, SocketAddr};
use std::str::FromStr;

use anyhow::Context;
use tokio::io::{AsyncRead, AsyncWrite};

use cln_plugin::ConfiguredPlugin;

use crate::{PluginState, OPTION_GRPC_HOST, OPTION_GRPC_PORT, OPTION_GRPC_SCHEME};

#[derive(Clone, Debug, PartialEq)]
pub enum GrpcRouterScheme {
HTTP,
HTTPS,
}

impl FromStr for GrpcRouterScheme {
type Err = anyhow::Error;

fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"http" => Ok(GrpcRouterScheme::HTTP),
"https" => Ok(GrpcRouterScheme::HTTPS),
_ => anyhow::bail!("Invalid scheme"),
}
}
}

pub struct GrpcRouterConfig {
pub scheme: GrpcRouterScheme,
pub host: IpAddr,
pub port: u16,
}

impl GrpcRouterConfig {
pub fn from_configured_plugin<I, O>(
plugin: &ConfiguredPlugin<PluginState, I, O>,
) -> anyhow::Result<Option<Self>>
where
I: AsyncRead + Send + Unpin + 'static,
O: AsyncWrite + Send + Unpin + 'static,
{
let port = plugin.option(&OPTION_GRPC_PORT).unwrap();
let port = u16::try_from(port).with_context(|| {
format!(
"Invalid config for {}. The value {} is out-of-bounds.",
OPTION_GRPC_PORT.name(),
port
)
})?;

let scheme = plugin.option(&OPTION_GRPC_SCHEME).unwrap();
let scheme = scheme.parse::<GrpcRouterScheme>().with_context(|| {
format!(
"Invalid config for {}. The config '{}' is invalid. Use either 'http' or 'https'.",
OPTION_GRPC_SCHEME.name(),
scheme
)
})?;

let host = plugin.option(&OPTION_GRPC_HOST).unwrap();
let host = host.parse::<IpAddr>().with_context(|| {
format!(
"Invalid config for {}. '{}' is not a valid ip-address.",
OPTION_GRPC_HOST.name(),
host
)
})?;

if GrpcRouterScheme::HTTP == scheme && !host.is_loopback() {
anyhow::bail!("Invalid config: Scheme 'http' is only allowed on a loopback address. Try setting {} to 127.0.0.1",
OPTION_GRPC_HOST.name());
}

Ok(Some(GrpcRouterConfig { scheme, host, port }))
}

pub fn socket_addr(&self) -> SocketAddr {
SocketAddr::new(self.host, self.port)
}
}
28 changes: 27 additions & 1 deletion tests/test_cln_rs.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

def wait_for_grpc_start(node):
"""This can happen before "public key" which start() swallows"""
wait_for(lambda: node.daemon.is_in_log(r'serving grpc'))
wait_for(lambda: node.daemon.is_in_log(r'serving grpc on '))


def test_rpc_client(node_factory):
Expand Down Expand Up @@ -105,6 +105,32 @@ def test_plugin_options_handle_defaults(node_factory):
assert opts["multi-i64-option-default"] == [-42]


def test_grpc_connect_http(node_factory):
"""Attempts to connect to the grpc interface and call getinfo over http"""
grpc_port = node_factory.get_unused_port()
l1 = node_factory.get_node(options={"grpc-port": str(grpc_port), "grpc-scheme": "http"})

wait_for_grpc_start(l1)

channel = grpc.insecure_channel(f"localhost:{l1.grpc_port}")
stub = clnpb.NodeStub(channel)

response = stub.Getinfo(clnpb.GetinfoRequest())
print(response)


def test_grpc_connection_refuses_non_loopback_address(node_factory):
grpc_port = node_factory.get_unused_port()
options = {
"grpc-port": str(grpc_port),
"grpc-scheme": "http",
"grpc-host": "0.0.0.0"
}

l1 = node_factory.get_node(options=options)
assert l1.daemon.is_in_log(r'Scheme \'http\' is only allowed on a loopback address')


def test_grpc_connect(node_factory):
"""Attempts to connect to the grpc interface and call getinfo"""
# These only exist if we have rust!
Expand Down
Loading