Skip to content
This repository was archived by the owner on Feb 19, 2025. It is now read-only.

Commit cd34000

Browse files
committedSep 18, 2022
initial commit
0 parents  commit cd34000

File tree

7 files changed

+2012
-0
lines changed

7 files changed

+2012
-0
lines changed
 

‎.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/target
2+
.DS_Store

‎Cargo.lock

+1,651
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
[package]
2+
name = "hass-alexa-relay"
3+
version = "0.1.0"
4+
edition = "2021"
5+
6+
[dependencies]
7+
anyhow = { version = "1.0.65", features = ["backtrace"] }
8+
clap = { version = "3.2.21", features = ["derive", "env"] }
9+
lambda_runtime = "0.6.1"
10+
log = "0.4.17"
11+
onetun = { version = "0.3.3", default-features = false }
12+
pretty_env_logger = "0.4.0"
13+
reqwest = { version = "0.11.11", features = ["rustls-tls-native-roots"], default-features = false }
14+
serde = { version = "1.0.144", features = ["serde_derive"] }
15+
serde_json = "1.0.85"
16+
tokio = { version = "1.21.1", features = ["tokio-macros"] }

‎README.md

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# HASS Alexa Relay
2+
3+
An AWS lambda function that enables the connection between Amazons Alexa and a Home Assistant instance via a wireguard VPN tunnel.
4+
5+
## Why?
6+
This lambda implementation of the home assistant Alexa skill lambda enables the connection to a home assistant instance that is only accessible via a Wireguard VPN.
7+
8+
## Installation
9+
10+
### Pre-built Binary
11+
Download the pre-built binary from the release page and upload the zip file to your lambda function. Make sure to set the runtime to `Custom runtime on Amazon Linux 2`,
12+
the architecture to `x86_64` and under Configuration > General Configuration set the timeout to about 15 seconds.
13+
14+
### Build from source
15+
Make sure to have a properly set up rust build environment. Install `cargo-lambda` via `cargo install cargo-lambda`.
16+
Build the crate via `cargo lambda build --release --output-format Zip`. Afterwards follow the steps for the pre-build version.

‎src/lambda.rs

+151
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
use anyhow::{anyhow, Context, Result};
2+
use lambda_runtime::LambdaEvent;
3+
use reqwest::{
4+
header::{HeaderMap, HeaderValue},
5+
StatusCode,
6+
};
7+
use serde::Deserialize;
8+
use serde_json::json;
9+
10+
#[derive(Deserialize)]
11+
struct LambdaPayload {
12+
directive: LambdaDirective,
13+
}
14+
15+
#[derive(Deserialize)]
16+
struct LambdaDirective {
17+
header: LambdaDirectiveHeaders,
18+
endpoint: Option<LambdaDirectiveEndpoint>,
19+
payload: Option<LambdaDirectivePayload>,
20+
}
21+
22+
#[derive(Deserialize)]
23+
struct LambdaDirectiveHeaders {
24+
#[serde(rename = "payloadVersion")]
25+
payload_version: String,
26+
}
27+
28+
#[derive(Deserialize)]
29+
struct LambdaDirectiveEndpoint {
30+
scope: LambdaDirectiveScope,
31+
}
32+
33+
#[derive(Deserialize)]
34+
struct LambdaDirectivePayload {
35+
grantee: Option<LambdaDirectiveScope>,
36+
scope: Option<LambdaDirectiveScope>,
37+
}
38+
39+
#[derive(Deserialize)]
40+
struct LambdaDirectiveScope {
41+
#[serde(rename = "type")]
42+
ty: String,
43+
token: Option<String>,
44+
}
45+
46+
/// Handle incoming Alexa directive.
47+
pub async fn lambda_handler(
48+
event: LambdaEvent<serde_json::Value>,
49+
access_token: Option<String>,
50+
) -> Result<serde_json::Value> {
51+
let (event, _context) = event.into_parts();
52+
let payload: LambdaPayload =
53+
serde_json::from_value(event.clone()).context("Failed to parse event payload")?;
54+
55+
let base_url = "http://127.0.0.1:8080"; // os.environ.get('BASE_URL')
56+
57+
if payload.directive.header.payload_version != "3" {
58+
return Err(anyhow!("only payload version 3 is supported!"));
59+
}
60+
61+
let scope = payload
62+
.directive
63+
.endpoint
64+
.as_ref()
65+
.map(|e| &e.scope)
66+
.or_else(|| {
67+
payload
68+
.directive
69+
.payload
70+
.as_ref()
71+
.and_then(|p| p.grantee.as_ref())
72+
})
73+
.or_else(|| {
74+
payload
75+
.directive
76+
.payload
77+
.as_ref()
78+
.and_then(|p| p.scope.as_ref())
79+
})
80+
.ok_or_else(|| anyhow!("missing endpoint.scope"))?;
81+
82+
if scope.ty != "BearerToken" {
83+
return Err(anyhow!("only BearerToken is supported"));
84+
}
85+
86+
let token = scope
87+
.token
88+
.clone()
89+
.or_else(|| access_token.map(|t| t.to_owned()))
90+
.ok_or_else(|| anyhow!("access token missing!"))?;
91+
92+
let client = reqwest::Client::new();
93+
let mut headers = HeaderMap::new();
94+
95+
headers.insert(
96+
"Authorization",
97+
HeaderValue::from_str(&format!("Bearer {token}"))
98+
.context("Failed to create auth header")?,
99+
);
100+
headers.insert("Content-Type", HeaderValue::from_static("application/json"));
101+
102+
let has_event_payload =
103+
serde_json::to_string(&event).context("Failed to serialize event payload!")?;
104+
105+
let response = client
106+
.post(format!("{base_url}/api/alexa/smart_home"))
107+
.headers(headers)
108+
.body(has_event_payload)
109+
.send()
110+
.await
111+
.context("Failed to send upstream hass request!")?;
112+
113+
let error_type = match response.status() {
114+
StatusCode::OK
115+
| StatusCode::CREATED
116+
| StatusCode::ACCEPTED
117+
| StatusCode::NON_AUTHORITATIVE_INFORMATION
118+
| StatusCode::NO_CONTENT
119+
| StatusCode::RESET_CONTENT
120+
| StatusCode::PARTIAL_CONTENT
121+
| StatusCode::MULTI_STATUS
122+
| StatusCode::ALREADY_REPORTED => None,
123+
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
124+
Some("INVALID_AUTHORIZATION_CREDENTIAL")
125+
}
126+
_ => Some("INTERNAL_ERROR"),
127+
};
128+
129+
let message = response
130+
.text_with_charset("utf-8")
131+
.await
132+
.context("Failed to decode hass response")?;
133+
let parsed_message: serde_json::Value =
134+
serde_json::from_str(&message).context("Failed to parse response message")?;
135+
136+
if let Some(error_type) = error_type {
137+
let err = json!({
138+
"event": {
139+
"payload": {
140+
"type": error_type,
141+
"message": message
142+
}
143+
}
144+
});
145+
146+
return Ok(err);
147+
}
148+
149+
log::info!("response from hass: {:?}", parsed_message);
150+
Ok(parsed_message)
151+
}

‎src/main.rs

+131
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
mod lambda;
2+
mod wireguard;
3+
4+
use std::{
5+
future::Future,
6+
net::{IpAddr, SocketAddr, ToSocketAddrs},
7+
task::{Context as TaskContext, Poll},
8+
};
9+
10+
use anyhow::{anyhow, Context, Result};
11+
use clap::Parser;
12+
use lambda::lambda_handler;
13+
use lambda_runtime::Service;
14+
use onetun::config::{X25519PublicKey, X25519SecretKey};
15+
use wireguard::start_wireguard;
16+
17+
#[derive(Parser)]
18+
#[clap(version, author)]
19+
struct Args {
20+
#[clap(flatten)]
21+
wireguard: WireguardArgs,
22+
23+
/// IP address and port of the home assistant instance on the local network
24+
#[clap(env, short, long, parse(try_from_str = to_socket_addrs))]
25+
ha_host: SocketAddr,
26+
27+
/// The desired log level. Options are 'error', 'info', 'debug'
28+
#[clap(env, short, long, default_value = "info")]
29+
log_level: String,
30+
31+
/// an optional home assistant log lived access token for testing.
32+
#[clap(env, short = 't', long)]
33+
long_lived_access_token: Option<String>,
34+
}
35+
36+
/// Wireguard specific arguments
37+
#[derive(clap::Args)]
38+
struct WireguardArgs {
39+
/// The wireguard endpoint that consists of either domain name or ip + port
40+
#[clap(env, short, long, parse(try_from_str = to_socket_addrs))]
41+
endpoint: SocketAddr,
42+
// Wireguard priavate key for this peer
43+
#[clap(env, short, long)]
44+
private_key: X25519SecretKey,
45+
/// Wireguard public key
46+
#[clap(env, short = 'k', long)]
47+
public_key: X25519PublicKey,
48+
/// The peer ip that has been choosen for this peer
49+
#[clap(env, short, long)]
50+
source_peer_ip: IpAddr,
51+
}
52+
53+
fn init_logger(log_level: &str) -> anyhow::Result<()> {
54+
let mut builder = pretty_env_logger::formatted_timed_builder();
55+
builder.parse_filters(log_level);
56+
builder
57+
.try_init()
58+
.with_context(|| "Failed to initialize logger")
59+
}
60+
61+
struct LambdaService<T> {
62+
f: T,
63+
access_token: Option<String>,
64+
}
65+
66+
impl<T> LambdaService<T> {
67+
fn new(f: T) -> Self {
68+
Self {
69+
f,
70+
access_token: None,
71+
}
72+
}
73+
74+
fn set_access_token(&mut self, token: String) {
75+
self.access_token = Some(token);
76+
}
77+
}
78+
79+
impl<T, F, Request, R, E> Service<Request> for LambdaService<T>
80+
where
81+
T: FnMut(Request, Option<String>) -> F,
82+
F: Future<Output = Result<R, E>>,
83+
{
84+
type Response = R;
85+
type Error = E;
86+
type Future = F;
87+
88+
fn poll_ready(&mut self, _: &mut TaskContext<'_>) -> Poll<Result<(), E>> {
89+
Ok(()).into()
90+
}
91+
92+
fn call(&mut self, req: Request) -> Self::Future {
93+
(self.f)(req, self.access_token.as_ref().map(|t| t.to_owned()))
94+
}
95+
}
96+
97+
#[tokio::main(flavor = "current_thread")]
98+
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
99+
let args = Args::parse();
100+
101+
init_logger(&args.log_level)?;
102+
103+
start_wireguard(
104+
args.wireguard.endpoint,
105+
args.wireguard.private_key,
106+
args.wireguard.public_key,
107+
args.wireguard.source_peer_ip,
108+
args.ha_host,
109+
args.log_level,
110+
)
111+
.await?;
112+
113+
let mut handler = LambdaService::new(lambda_handler);
114+
115+
if let Some(token) = args.long_lived_access_token {
116+
handler.set_access_token(token);
117+
}
118+
119+
lambda_runtime::run(handler).await
120+
}
121+
122+
fn to_socket_addrs<T>(value: T) -> Result<SocketAddr>
123+
where
124+
T: ToSocketAddrs,
125+
{
126+
value
127+
.to_socket_addrs()
128+
.context("Error during address resolution")?
129+
.next()
130+
.ok_or_else(|| anyhow!("Failed to resolve socket address!"))
131+
}

‎src/wireguard.rs

+45
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
use std::{
2+
net::{IpAddr, Ipv4Addr, SocketAddr},
3+
sync::Arc,
4+
};
5+
6+
use anyhow::{Context, Result};
7+
use onetun::{
8+
config::{PortForwardConfig, PortProtocol, X25519PublicKey, X25519SecretKey},
9+
events::Bus,
10+
};
11+
12+
pub async fn start_wireguard(
13+
endpoint: SocketAddr,
14+
private_key: X25519SecretKey,
15+
public_key: X25519PublicKey,
16+
source_peer_ip: IpAddr,
17+
ha_host: SocketAddr,
18+
log_level: String,
19+
) -> Result<()> {
20+
let config = onetun::config::Config {
21+
private_key: Arc::new(private_key),
22+
endpoint_public_key: Arc::new(public_key),
23+
endpoint_addr: endpoint,
24+
source_peer_ip,
25+
keepalive_seconds: Some(5),
26+
port_forwards: vec![PortForwardConfig {
27+
source: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080),
28+
destination: ha_host,
29+
protocol: PortProtocol::Tcp,
30+
remote: false,
31+
}],
32+
remote_port_forwards: Vec::with_capacity(0),
33+
endpoint_bind_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)), 0),
34+
max_transmission_unit: 1420,
35+
log: log_level,
36+
warnings: Vec::with_capacity(0),
37+
pcap_file: None,
38+
};
39+
40+
let bus = Bus::new();
41+
42+
onetun::start_tunnels(config, bus)
43+
.await
44+
.context("Failed to create wireguard tunnel")
45+
}

0 commit comments

Comments
 (0)
This repository has been archived.