4ward 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Cargo.lock +4321 -0
- package/Cargo.toml +8 -0
- package/LICENSE +201 -0
- package/README.md +119 -0
- package/bin/run.js +40 -0
- package/crates/4ward-cli/Cargo.toml +29 -0
- package/crates/4ward-cli/src/commands/alias.rs +55 -0
- package/crates/4ward-cli/src/commands/deploy.rs +267 -0
- package/crates/4ward-cli/src/commands/dns.rs +60 -0
- package/crates/4ward-cli/src/commands/init.rs +38 -0
- package/crates/4ward-cli/src/commands/keys.rs +113 -0
- package/crates/4ward-cli/src/commands/mod.rs +14 -0
- package/crates/4ward-cli/src/commands/request_prod.rs +38 -0
- package/crates/4ward-cli/src/commands/status.rs +21 -0
- package/crates/4ward-cli/src/main.rs +45 -0
- package/crates/4ward-cli/templates/template.yaml +388 -0
- package/crates/4ward-core/Cargo.toml +15 -0
- package/crates/4ward-core/src/config.rs +261 -0
- package/crates/4ward-core/src/dns.rs +126 -0
- package/crates/4ward-core/src/lib.rs +5 -0
- package/docs/assets/arch.svg +44 -0
- package/docs/assets/banner-lockup.jpg +0 -0
- package/docs/assets/icon.jpg +0 -0
- package/lambdas/api/Cargo.toml +22 -0
- package/lambdas/api/src/main.rs +360 -0
- package/lambdas/forwarder/Cargo.toml +29 -0
- package/lambdas/forwarder/src/arc.rs +258 -0
- package/lambdas/forwarder/src/main.rs +369 -0
- package/package.json +50 -0
- package/scripts/install-binary.js +30 -0
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
use super::{load_config, TEMPLATE};
|
|
2
|
+
use clap::Args;
|
|
3
|
+
use comfy_table::Table;
|
|
4
|
+
|
|
5
|
+
#[derive(Args)]
|
|
6
|
+
pub struct DeployArgs {
|
|
7
|
+
#[arg(long, default_value = "4ward.json")]
|
|
8
|
+
pub config: String,
|
|
9
|
+
#[arg(long)]
|
|
10
|
+
pub yes: bool,
|
|
11
|
+
/// Skip pushing built Lambda code (infra only)
|
|
12
|
+
#[arg(long)]
|
|
13
|
+
pub no_code: bool,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
#[derive(Args)]
|
|
17
|
+
pub struct DestroyArgs {
|
|
18
|
+
#[arg(long, default_value = "4ward.json")]
|
|
19
|
+
pub config: String,
|
|
20
|
+
#[arg(long)]
|
|
21
|
+
pub yes: bool,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
fn slug(domain: &str) -> String {
|
|
25
|
+
domain.replace('.', "-")
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
fn stack_for(cfg: &fourward_core::FourwardConfig, domain: &str) -> String {
|
|
29
|
+
if cfg.domains.len() == 1 {
|
|
30
|
+
cfg.aws.stack_name.clone()
|
|
31
|
+
} else {
|
|
32
|
+
format!("{}-{}", cfg.aws.stack_name, slug(domain))
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
fn alias_map_json(cfg: &fourward_core::FourwardConfig, domain: &str) -> String {
|
|
37
|
+
let mut m = std::collections::HashMap::new();
|
|
38
|
+
for d in &cfg.domains {
|
|
39
|
+
if d.domain.eq_ignore_ascii_case(domain) {
|
|
40
|
+
for (a, dests) in &d.routes {
|
|
41
|
+
m.insert(format!("{a}@{}", d.domain), dests.clone());
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
serde_json::to_string(&m).unwrap_or("{}".into())
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
pub async fn run(args: DeployArgs) -> anyhow::Result<()> {
|
|
49
|
+
let cfg = load_config(&args.config)?;
|
|
50
|
+
let aws_cfg = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
|
|
51
|
+
// 1. Validate session.
|
|
52
|
+
let sts = aws_sdk_sts::Client::new(&aws_cfg);
|
|
53
|
+
let id = sts.get_caller_identity().send().await?;
|
|
54
|
+
println!("aws account: {}", id.account().unwrap_or("?"));
|
|
55
|
+
|
|
56
|
+
// 2. Build lambdas once (best-effort); zips reused for every domain stack.
|
|
57
|
+
let zips = if args.no_code { Vec::new() } else { try_build_lambdas() };
|
|
58
|
+
|
|
59
|
+
let cf = aws_sdk_cloudformation::Client::new(&aws_cfg);
|
|
60
|
+
for d in &cfg.domains {
|
|
61
|
+
deploy_one(&cf, &aws_cfg, &cfg, d, &zips).await?;
|
|
62
|
+
}
|
|
63
|
+
// 3. DNS: Route53 note or ASCII table for external.
|
|
64
|
+
sync_dns(&aws_cfg, &cfg).await;
|
|
65
|
+
Ok(())
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
#[allow(clippy::too_many_lines)]
|
|
69
|
+
async fn deploy_one(
|
|
70
|
+
cf: &aws_sdk_cloudformation::Client,
|
|
71
|
+
aws_cfg: &aws_config::SdkConfig,
|
|
72
|
+
cfg: &fourward_core::FourwardConfig,
|
|
73
|
+
d: &fourward_core::DomainConfig,
|
|
74
|
+
zips: &[(String, Vec<u8>)],
|
|
75
|
+
) -> anyhow::Result<()> {
|
|
76
|
+
let stack = stack_for(cfg, &d.domain);
|
|
77
|
+
let (arc_selector, arc_key) = detect_arc(aws_cfg, &d.domain).await;
|
|
78
|
+
let params = vec![
|
|
79
|
+
("ProjectName", stack.clone()),
|
|
80
|
+
("DomainName", d.domain.clone()),
|
|
81
|
+
("RelayDomain", d.domain.clone()),
|
|
82
|
+
("AliasMapJson", alias_map_json(cfg, &d.domain)),
|
|
83
|
+
("CatchAll", d.catch_all.clone().unwrap_or_default()),
|
|
84
|
+
("BannerEnabled", cfg.settings.banner_enabled.to_string()),
|
|
85
|
+
("ApiEnabled", cfg.api.enabled.to_string()),
|
|
86
|
+
("AllowedDomains", cfg.domains.iter().map(|d| d.domain.clone()).collect::<Vec<_>>().join(",")),
|
|
87
|
+
("RetentionDays", cfg.settings.retention_days.to_string()),
|
|
88
|
+
("RateLimit", cfg.api.rate_limit.requests_per_second.to_string()),
|
|
89
|
+
("Burst", cfg.api.rate_limit.burst.to_string()),
|
|
90
|
+
("ArcSelector", arc_selector),
|
|
91
|
+
("ArcKeySsm", arc_key),
|
|
92
|
+
];
|
|
93
|
+
let parameters: Vec<aws_sdk_cloudformation::types::Parameter> = params
|
|
94
|
+
.into_iter()
|
|
95
|
+
.map(|(k, v)| {
|
|
96
|
+
aws_sdk_cloudformation::types::Parameter::builder()
|
|
97
|
+
.parameter_key(k)
|
|
98
|
+
.parameter_value(v)
|
|
99
|
+
.build()
|
|
100
|
+
})
|
|
101
|
+
.collect();
|
|
102
|
+
|
|
103
|
+
let exists = cf.describe_stacks().stack_name(&stack).send().await.is_ok();
|
|
104
|
+
if exists {
|
|
105
|
+
println!("updating stack {stack}…");
|
|
106
|
+
let r = cf.update_stack()
|
|
107
|
+
.stack_name(&stack)
|
|
108
|
+
.template_body(TEMPLATE)
|
|
109
|
+
.set_parameters(Some(parameters))
|
|
110
|
+
.capabilities(aws_sdk_cloudformation::types::Capability::CapabilityIam)
|
|
111
|
+
.send()
|
|
112
|
+
.await;
|
|
113
|
+
match r {
|
|
114
|
+
Ok(_) => println!("update initiated"),
|
|
115
|
+
Err(e) => {
|
|
116
|
+
let s = format!("{e:?}");
|
|
117
|
+
if s.contains("No updates") {
|
|
118
|
+
println!("no updates to perform");
|
|
119
|
+
} else {
|
|
120
|
+
return Err(e.into());
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
println!("creating stack {stack}…");
|
|
126
|
+
cf.create_stack()
|
|
127
|
+
.stack_name(&stack)
|
|
128
|
+
.template_body(TEMPLATE)
|
|
129
|
+
.set_parameters(Some(parameters))
|
|
130
|
+
.capabilities(aws_sdk_cloudformation::types::Capability::CapabilityIam)
|
|
131
|
+
.send()
|
|
132
|
+
.await?;
|
|
133
|
+
}
|
|
134
|
+
println!("waiting for {stack}…");
|
|
135
|
+
// Simple poll loop instead of waiters (fewer deps).
|
|
136
|
+
for _ in 0..60 {
|
|
137
|
+
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
|
138
|
+
if let Ok(o) = cf.describe_stacks().stack_name(&stack).send().await {
|
|
139
|
+
if let Some(s) = o.stacks().first() {
|
|
140
|
+
let st = format!("{:?}", s.stack_status());
|
|
141
|
+
println!(" status: {st}");
|
|
142
|
+
if st.contains("COMPLETE") && !st.contains("PROGRESS") {
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
if st.contains("FAILED") || st.contains("ROLLBACK_COMPLETE") {
|
|
146
|
+
anyhow::bail!("stack failed: {st}");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// 4. Push real Lambda code (template ships a placeholder otherwise).
|
|
152
|
+
if !zips.is_empty() {
|
|
153
|
+
push_code(aws_cfg, &stack).await;
|
|
154
|
+
}
|
|
155
|
+
Ok(())
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/// ARC auto-detect: `keys arc` stores PEM at /4ward/arc/<domain> (+ selector).
|
|
159
|
+
async fn detect_arc(aws_cfg: &aws_config::SdkConfig, domain: &str) -> (String, String) {
|
|
160
|
+
let ssm = aws_sdk_ssm::Client::new(aws_cfg);
|
|
161
|
+
let key = format!("/4ward/arc/{domain}");
|
|
162
|
+
if ssm.get_parameter().name(&key).with_decryption(true).send().await.is_err() {
|
|
163
|
+
return (String::new(), String::new());
|
|
164
|
+
}
|
|
165
|
+
let sel = ssm.get_parameter().name(format!("{key}/selector")).send().await.ok()
|
|
166
|
+
.and_then(|o| o.parameter().and_then(|p| p.value().map(|s| s.to_string())))
|
|
167
|
+
.unwrap_or_else(|| "fw1".to_string());
|
|
168
|
+
println!("ARC key found for {domain} (selector {sel})");
|
|
169
|
+
(sel, key)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/// Build both lambdas to bootstrap.zip; returns (func_suffix, zip_bytes).
|
|
173
|
+
fn try_build_lambdas() -> Vec<(String, Vec<u8>)> {
|
|
174
|
+
let ok = std::process::Command::new("cargo").arg("lambda").arg("--version").output().map(|o| o.status.success()).unwrap_or(false);
|
|
175
|
+
if !ok {
|
|
176
|
+
println!("cargo-lambda not found — deploying with placeholder code; run `cargo install cargo-lambda` for real traffic");
|
|
177
|
+
return Vec::new();
|
|
178
|
+
}
|
|
179
|
+
let mut out = Vec::new();
|
|
180
|
+
for (pkg, func) in [("lambda-forwarder", "forwarder"), ("lambda-api", "api")] {
|
|
181
|
+
println!("building {pkg} (arm64)…");
|
|
182
|
+
let st = std::process::Command::new("cargo").args(["lambda", "build", "--arm64", "--release", "--package", pkg, "--output-format", "zip"]).status();
|
|
183
|
+
match st {
|
|
184
|
+
Ok(s) if s.success() => {
|
|
185
|
+
let zip = format!("target/lambda/{pkg}/bootstrap.zip");
|
|
186
|
+
match std::fs::read(&zip) {
|
|
187
|
+
Ok(b) => {
|
|
188
|
+
println!("{pkg} built ({} bytes)", b.len());
|
|
189
|
+
out.push((func.to_string(), b));
|
|
190
|
+
}
|
|
191
|
+
Err(_) => println!("{pkg} built but {zip} missing — placeholder kept"),
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
_ => println!("{pkg} build failed — continuing with placeholder"),
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
out
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async fn push_code(aws_cfg: &aws_config::SdkConfig, stack: &str) {
|
|
201
|
+
let lambda = aws_sdk_lambda::Client::new(aws_cfg);
|
|
202
|
+
// zips rebuilt per deploy; re-read from disk (single source of truth).
|
|
203
|
+
for (pkg, func) in [("lambda-forwarder", "forwarder"), ("lambda-api", "api")] {
|
|
204
|
+
let zip = format!("target/lambda/{pkg}/bootstrap.zip");
|
|
205
|
+
let bytes = match std::fs::read(&zip) {
|
|
206
|
+
Ok(b) => b,
|
|
207
|
+
Err(_) => continue,
|
|
208
|
+
};
|
|
209
|
+
let name = format!("{stack}-{func}");
|
|
210
|
+
println!("updating code for {name}…");
|
|
211
|
+
match lambda.update_function_code().function_name(&name).zip_file(aws_sdk_lambda::primitives::Blob::new(bytes)).send().await {
|
|
212
|
+
Ok(_) => println!("{name} updated"),
|
|
213
|
+
Err(e) => println!("{name} code update failed: {e:?} (infra is live with previous code)"),
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async fn sync_dns(aws_cfg: &aws_config::SdkConfig, cfg: &fourward_core::FourwardConfig) {
|
|
219
|
+
let r53 = aws_sdk_route53::Client::new(aws_cfg);
|
|
220
|
+
for d in &cfg.domains {
|
|
221
|
+
let hosted = r53.list_hosted_zones_by_name().dns_name(&d.domain).send().await.ok()
|
|
222
|
+
.and_then(|o| o.hosted_zones().first().cloned());
|
|
223
|
+
let is_route53 = matches!(d.dns_provider, fourward_core::DnsProvider::Route53) && hosted.is_some();
|
|
224
|
+
if is_route53 {
|
|
225
|
+
println!("Route53 zone found for {} — add MX/SPF/DKIM via `4ward dns --format bind` or console", d.domain);
|
|
226
|
+
} else {
|
|
227
|
+
println!("external DNS for {} — add these records:", d.domain);
|
|
228
|
+
print_dns(cfg);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
fn print_dns(cfg: &fourward_core::FourwardConfig) {
|
|
234
|
+
let mut t = Table::new();
|
|
235
|
+
t.set_header(vec!["Type", "Name", "Value"]);
|
|
236
|
+
let region = &cfg.aws.region;
|
|
237
|
+
for d in &cfg.domains {
|
|
238
|
+
for r in fourward_core::records_for_domain(&d.domain, region, &fourward_core::placeholder_dkim_tokens()) {
|
|
239
|
+
let val = if r.record_type == "CNAME" && r.value.starts_with("token") {
|
|
240
|
+
format!("{} (see SES console for real DKIM token)", r.value)
|
|
241
|
+
} else {
|
|
242
|
+
r.value.clone()
|
|
243
|
+
};
|
|
244
|
+
t.add_row(vec![r.record_type.clone(), r.name.clone(), val]);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
println!("{t}");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
pub async fn destroy(args: DestroyArgs) -> anyhow::Result<()> {
|
|
251
|
+
let cfg = load_config(&args.config)?;
|
|
252
|
+
let stacks: Vec<String> = cfg.domains.iter().map(|d| stack_for(&cfg, &d.domain)).collect();
|
|
253
|
+
if !args.yes {
|
|
254
|
+
let ok = inquire::Confirm::new(&format!("Delete stacks {}?", stacks.join(", "))).with_default(false).prompt()?;
|
|
255
|
+
if !ok {
|
|
256
|
+
println!("aborted");
|
|
257
|
+
return Ok(());
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
let aws_cfg = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
|
|
261
|
+
let cf = aws_sdk_cloudformation::Client::new(&aws_cfg);
|
|
262
|
+
for stack in &stacks {
|
|
263
|
+
cf.delete_stack().stack_name(stack).send().await?;
|
|
264
|
+
println!("delete initiated for {stack}");
|
|
265
|
+
}
|
|
266
|
+
Ok(())
|
|
267
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
use super::load_config;
|
|
2
|
+
use clap::Args;
|
|
3
|
+
use comfy_table::Table;
|
|
4
|
+
|
|
5
|
+
#[derive(Args)]
|
|
6
|
+
pub struct DnsArgs {
|
|
7
|
+
#[arg(long, default_value = "4ward.json")]
|
|
8
|
+
pub config: String,
|
|
9
|
+
#[arg(long, default_value = "table", value_parser = ["table", "json", "bind"])]
|
|
10
|
+
pub format: String,
|
|
11
|
+
#[arg(long, default_value = "us-east-1")]
|
|
12
|
+
pub region: String,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
pub async fn run(args: DnsArgs) -> anyhow::Result<()> {
|
|
16
|
+
let cfg = load_config(&args.config).unwrap_or_else(|_| {
|
|
17
|
+
// Allow `4ward dns` with explicit domain-less fallback? Require config.
|
|
18
|
+
eprintln!("cannot load config, using empty");
|
|
19
|
+
std::process::exit(1);
|
|
20
|
+
});
|
|
21
|
+
let region = if cfg.aws.region.is_empty() { args.region } else { cfg.aws.region.clone() };
|
|
22
|
+
// Try live DKIM tokens via SES; fall back to placeholders (external DNS pre-deploy).
|
|
23
|
+
let tokens = fetch_dkim_tokens(&cfg.domains.first().map(|d| d.domain.clone()).unwrap_or_default(), ®ion).await
|
|
24
|
+
.unwrap_or_else(fourward_core::placeholder_dkim_tokens);
|
|
25
|
+
|
|
26
|
+
let mut all = Vec::new();
|
|
27
|
+
for d in &cfg.domains {
|
|
28
|
+
all.extend(fourward_core::records_for_domain(&d.domain, ®ion, &tokens));
|
|
29
|
+
}
|
|
30
|
+
match args.format.as_str() {
|
|
31
|
+
"json" => println!("{}", serde_json::to_string_pretty(&all)?),
|
|
32
|
+
"bind" => {
|
|
33
|
+
for r in &all {
|
|
34
|
+
println!("{}.\t{}\tIN\t{}\t{}", r.name, r.ttl, r.record_type, r.value);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
_ => {
|
|
38
|
+
let mut t = Table::new();
|
|
39
|
+
t.set_header(vec!["Type", "Name", "Value", "Purpose"]);
|
|
40
|
+
for r in &all {
|
|
41
|
+
t.add_row(vec![r.record_type.clone(), r.name.clone(), r.value.clone(), r.purpose.clone()]);
|
|
42
|
+
}
|
|
43
|
+
println!("{t}");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
Ok(())
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async fn fetch_dkim_tokens(domain: &str, region: &str) -> Option<Vec<String>> {
|
|
50
|
+
if domain.is_empty() {
|
|
51
|
+
return None;
|
|
52
|
+
}
|
|
53
|
+
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
|
|
54
|
+
let client = aws_sdk_sesv2::Client::new(&config);
|
|
55
|
+
let _ = region;
|
|
56
|
+
let out = client.get_email_identity().email_identity(domain).send().await.ok()?;
|
|
57
|
+
out.dkim_attributes()
|
|
58
|
+
.map(|d| d.tokens().to_vec())
|
|
59
|
+
.filter(|t| t.len() == 3)
|
|
60
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
use clap::Args;
|
|
2
|
+
use fourward_core::{ApiConfig, AwsConfig, DomainConfig, DnsProvider, EngineSettings, FourwardConfig, RateLimitConfig};
|
|
3
|
+
use std::collections::HashMap;
|
|
4
|
+
|
|
5
|
+
#[derive(Args)]
|
|
6
|
+
pub struct InitArgs {
|
|
7
|
+
#[arg(long, default_value = "4ward.json")]
|
|
8
|
+
pub output: String,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
pub async fn run(args: InitArgs) -> anyhow::Result<()> {
|
|
12
|
+
let domain = inquire::Text::new("Primary domain (e.g. example.com)").prompt()?;
|
|
13
|
+
let region = inquire::Text::new("AWS region").with_default("us-east-1").prompt()?;
|
|
14
|
+
let stack = inquire::Text::new("Stack name").with_default(&format!("4ward-{}", domain.replace('.', "-"))).prompt()?;
|
|
15
|
+
let alias = inquire::Text::new("First alias (local part)").with_default("hello").prompt()?;
|
|
16
|
+
let dest = inquire::Text::new("Where should it forward to?").prompt()?;
|
|
17
|
+
let dns_provider = inquire::Select::new("DNS provider", vec!["route53", "external"]).prompt()?.to_string();
|
|
18
|
+
|
|
19
|
+
let mut routes = HashMap::new();
|
|
20
|
+
routes.insert(alias, vec![dest]);
|
|
21
|
+
let cfg = FourwardConfig {
|
|
22
|
+
version: "1".into(),
|
|
23
|
+
project: "4ward".into(),
|
|
24
|
+
aws: AwsConfig { region, profile: "default".into(), stack_name: stack },
|
|
25
|
+
api: ApiConfig { enabled: true, cors: vec!["*".into()], rate_limit: RateLimitConfig { requests_per_second: 100, burst: 200 } },
|
|
26
|
+
settings: EngineSettings { banner_enabled: true, retention_days: 1, sender_format: "{name} (via {alias}) <relay@{domain}>".into() },
|
|
27
|
+
domains: vec![DomainConfig {
|
|
28
|
+
domain,
|
|
29
|
+
dns_provider: if dns_provider == "route53" { DnsProvider::Route53 } else { DnsProvider::External },
|
|
30
|
+
catch_all: None,
|
|
31
|
+
routes,
|
|
32
|
+
}],
|
|
33
|
+
};
|
|
34
|
+
cfg.validate().map_err(|e| anyhow::anyhow!("{e}"))?;
|
|
35
|
+
std::fs::write(&args.output, serde_json::to_string_pretty(&cfg)?)?;
|
|
36
|
+
println!("wrote {}", args.output);
|
|
37
|
+
Ok(())
|
|
38
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
use clap::{Args, Subcommand};
|
|
2
|
+
|
|
3
|
+
#[derive(Args)]
|
|
4
|
+
pub struct KeysArgs {
|
|
5
|
+
#[command(subcommand)]
|
|
6
|
+
pub cmd: KeyCmd,
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
#[derive(Subcommand)]
|
|
10
|
+
pub enum KeyCmd {
|
|
11
|
+
/// Generate Bearer token and store in SSM at /4ward/api-keys/<name>
|
|
12
|
+
Create {
|
|
13
|
+
name: String,
|
|
14
|
+
#[arg(long, default_value = "/4ward/api-keys/")]
|
|
15
|
+
prefix: String,
|
|
16
|
+
},
|
|
17
|
+
/// List key names under prefix (names only, never values)
|
|
18
|
+
List {
|
|
19
|
+
#[arg(long, default_value = "/4ward/api-keys/")]
|
|
20
|
+
prefix: String,
|
|
21
|
+
},
|
|
22
|
+
/// Delete a key
|
|
23
|
+
Revoke {
|
|
24
|
+
name: String,
|
|
25
|
+
#[arg(long, default_value = "/4ward/api-keys/")]
|
|
26
|
+
prefix: String,
|
|
27
|
+
},
|
|
28
|
+
/// Generate ARC seal RSA keypair for a domain (deploy auto-wires it)
|
|
29
|
+
Arc {
|
|
30
|
+
domain: String,
|
|
31
|
+
#[arg(long, default_value = "fw1")]
|
|
32
|
+
selector: String,
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
pub async fn run(args: KeysArgs) -> anyhow::Result<()> {
|
|
37
|
+
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
|
|
38
|
+
let ssm = aws_sdk_ssm::Client::new(&config);
|
|
39
|
+
match args.cmd {
|
|
40
|
+
KeyCmd::Create { name, prefix } => {
|
|
41
|
+
let token = gen_token();
|
|
42
|
+
ssm.put_parameter()
|
|
43
|
+
.name(format!("{prefix}{name}"))
|
|
44
|
+
.value(&token)
|
|
45
|
+
.r#type(aws_sdk_ssm::types::ParameterType::SecureString)
|
|
46
|
+
.overwrite(true)
|
|
47
|
+
.send()
|
|
48
|
+
.await?;
|
|
49
|
+
println!("{token}");
|
|
50
|
+
eprintln!("stored at {prefix}{name} — copy it now, it won't be shown again");
|
|
51
|
+
}
|
|
52
|
+
KeyCmd::List { prefix } => {
|
|
53
|
+
let out = ssm.describe_parameters()
|
|
54
|
+
.parameter_filters(aws_sdk_ssm::types::ParameterStringFilter::builder().key("Name").option("BeginsWith").values(&prefix).build()?)
|
|
55
|
+
.send().await?;
|
|
56
|
+
for p in out.parameters() {
|
|
57
|
+
println!("{}", p.name().unwrap_or(""));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
KeyCmd::Revoke { name, prefix } => {
|
|
61
|
+
ssm.delete_parameter().name(format!("{prefix}{name}")).send().await?;
|
|
62
|
+
println!("revoked {prefix}{name}");
|
|
63
|
+
}
|
|
64
|
+
KeyCmd::Arc { domain, selector } => {
|
|
65
|
+
let (pem, pub_b64) = gen_arc_keypair()?;
|
|
66
|
+
let key_param = format!("/4ward/arc/{domain}");
|
|
67
|
+
ssm.put_parameter().name(&key_param).value(&pem)
|
|
68
|
+
.r#type(aws_sdk_ssm::types::ParameterType::SecureString)
|
|
69
|
+
.overwrite(true).send().await?;
|
|
70
|
+
ssm.put_parameter().name(format!("{key_param}/selector")).value(&selector)
|
|
71
|
+
.r#type(aws_sdk_ssm::types::ParameterType::String)
|
|
72
|
+
.overwrite(true).send().await?;
|
|
73
|
+
let rec = fourward_core::arc_record(&selector, &domain, &pub_b64);
|
|
74
|
+
println!("private key → {key_param} (deploy wires it automatically)");
|
|
75
|
+
println!("add this DNS record:\n{} TXT {} \"{}\"", rec.name, rec.ttl, rec.value);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
Ok(())
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
fn gen_token() -> String {
|
|
82
|
+
use rand::RngCore;
|
|
83
|
+
let mut b = [0u8; 16];
|
|
84
|
+
rand::thread_rng().fill_bytes(&mut b);
|
|
85
|
+
format!("4w_live_{}", hex::encode(b))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/// Generate RSA-2048 ARC seal keypair. Returns (private PEM, public DER base64).
|
|
89
|
+
pub fn gen_arc_keypair() -> anyhow::Result<(String, String)> {
|
|
90
|
+
use base64::{engine::general_purpose::STANDARD as B64, Engine};
|
|
91
|
+
use rsa::pkcs1::{EncodeRsaPrivateKey, EncodeRsaPublicKey};
|
|
92
|
+
let key = rsa::RsaPrivateKey::new(&mut rand::thread_rng(), 2048)?;
|
|
93
|
+
let pem = key.to_pkcs1_pem(rsa::pkcs1::LineEnding::LF)?.to_string();
|
|
94
|
+
let der_b64 = B64.encode(key.to_public_key().to_pkcs1_der()?.as_bytes());
|
|
95
|
+
Ok((pem, der_b64))
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
#[cfg(test)]
|
|
99
|
+
mod tests {
|
|
100
|
+
use super::*;
|
|
101
|
+
#[test]
|
|
102
|
+
fn token_shape() {
|
|
103
|
+
let t = gen_token();
|
|
104
|
+
assert!(t.starts_with("4w_live_"));
|
|
105
|
+
assert_eq!(t.len(), "4w_live_".len() + 32);
|
|
106
|
+
}
|
|
107
|
+
#[test]
|
|
108
|
+
fn arc_keypair_shape() {
|
|
109
|
+
let (pem, b64) = gen_arc_keypair().unwrap();
|
|
110
|
+
assert!(pem.contains("BEGIN RSA PRIVATE KEY"));
|
|
111
|
+
assert!(b64.len() > 200);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
pub mod alias;
|
|
2
|
+
pub mod deploy;
|
|
3
|
+
pub mod dns;
|
|
4
|
+
pub mod init;
|
|
5
|
+
pub mod keys;
|
|
6
|
+
pub mod request_prod;
|
|
7
|
+
pub mod status;
|
|
8
|
+
|
|
9
|
+
pub const TEMPLATE: &str = include_str!("../../templates/template.yaml");
|
|
10
|
+
|
|
11
|
+
pub fn load_config(path: &str) -> anyhow::Result<fourward_core::FourwardConfig> {
|
|
12
|
+
let s = std::fs::read_to_string(path).map_err(|_| anyhow::anyhow!("config not found: {path} (run `4ward init`)"))?;
|
|
13
|
+
fourward_core::FourwardConfig::from_json(&s).map_err(|e| anyhow::anyhow!("{e}"))
|
|
14
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
use clap::Args;
|
|
2
|
+
|
|
3
|
+
#[derive(Args)]
|
|
4
|
+
pub struct RequestProdArgs {
|
|
5
|
+
#[arg(long)]
|
|
6
|
+
pub website: Option<String>,
|
|
7
|
+
#[arg(long)]
|
|
8
|
+
pub contact: Option<String>,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
pub async fn run(args: RequestProdArgs) -> anyhow::Result<()> {
|
|
12
|
+
let website = match args.website {
|
|
13
|
+
Some(w) => w,
|
|
14
|
+
None => inquire::Text::new("Project website URL").prompt()?,
|
|
15
|
+
};
|
|
16
|
+
let contact = match args.contact {
|
|
17
|
+
Some(c) => c,
|
|
18
|
+
None => inquire::Text::new("Operations contact email").prompt()?,
|
|
19
|
+
};
|
|
20
|
+
let description = format!(
|
|
21
|
+
"Transactional email for {website}. Automated bounce/complaint handling via SES reputation alarms, \
|
|
22
|
+
transactional account events only (verification codes, receipts, alerts), double opt-in for any marketing. \
|
|
23
|
+
Contact: {contact}."
|
|
24
|
+
);
|
|
25
|
+
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
|
|
26
|
+
let ses = aws_sdk_sesv2::Client::new(&config);
|
|
27
|
+
ses.put_account_details()
|
|
28
|
+
.mail_type(aws_sdk_sesv2::types::MailType::Transactional)
|
|
29
|
+
.website_url(&website)
|
|
30
|
+
.contact_language("EN".into())
|
|
31
|
+
.use_case_description(&description)
|
|
32
|
+
.additional_contact_email_addresses(&contact)
|
|
33
|
+
.production_access_enabled(true)
|
|
34
|
+
.send()
|
|
35
|
+
.await?;
|
|
36
|
+
println!("production access request submitted for {website}");
|
|
37
|
+
Ok(())
|
|
38
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
use clap::Args;
|
|
2
|
+
|
|
3
|
+
#[derive(Args)]
|
|
4
|
+
pub struct StatusArgs {
|
|
5
|
+
#[arg(long, default_value = "4ward.json")]
|
|
6
|
+
pub config: String,
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
pub async fn run(_args: StatusArgs) -> anyhow::Result<()> {
|
|
10
|
+
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
|
|
11
|
+
let ses = aws_sdk_sesv2::Client::new(&config);
|
|
12
|
+
let acct = ses.get_account().send().await?;
|
|
13
|
+
let sandbox = !acct.production_access_enabled();
|
|
14
|
+
println!("Sandbox mode: {}", if sandbox { "YES (request-production to lift)" } else { "NO (production)" });
|
|
15
|
+
match acct.send_quota() {
|
|
16
|
+
Some(q) => println!("24h quota: {q:?}"),
|
|
17
|
+
None => println!("24h quota: n/a"),
|
|
18
|
+
}
|
|
19
|
+
println!("Enforcement: {:?}", acct.enforcement_status().map(|s| format!("{s:?}")));
|
|
20
|
+
Ok(())
|
|
21
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
mod commands;
|
|
2
|
+
|
|
3
|
+
use clap::{Parser, Subcommand};
|
|
4
|
+
|
|
5
|
+
#[derive(Parser)]
|
|
6
|
+
#[command(name = "4ward", version, about = "Serverless email infrastructure for AWS in Rust")]
|
|
7
|
+
struct Cli {
|
|
8
|
+
#[command(subcommand)]
|
|
9
|
+
cmd: Commands,
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
#[derive(Subcommand)]
|
|
13
|
+
enum Commands {
|
|
14
|
+
/// Interactive questionnaire — writes validated 4ward.json
|
|
15
|
+
Init(commands::init::InitArgs),
|
|
16
|
+
/// Compile lambdas (if cargo-lambda present) + deploy CloudFormation stack
|
|
17
|
+
Deploy(commands::deploy::DeployArgs),
|
|
18
|
+
/// SES sandbox, quota & bounce inspector
|
|
19
|
+
Status(commands::status::StatusArgs),
|
|
20
|
+
/// Automated SES production access request (sesv2:PutAccountDetails)
|
|
21
|
+
RequestProduction(commands::request_prod::RequestProdArgs),
|
|
22
|
+
/// DNS export (table, json, bind)
|
|
23
|
+
Dns(commands::dns::DnsArgs),
|
|
24
|
+
/// Bearer token manager (SSM /4ward/api-keys/*)
|
|
25
|
+
Keys(commands::keys::KeysArgs),
|
|
26
|
+
/// Hot alias routing editor (edits 4ward.json routes)
|
|
27
|
+
Alias(commands::alias::AliasArgs),
|
|
28
|
+
/// Tear down the CloudFormation stack
|
|
29
|
+
Destroy(commands::deploy::DestroyArgs),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
#[tokio::main]
|
|
33
|
+
async fn main() -> anyhow::Result<()> {
|
|
34
|
+
let cli = Cli::parse();
|
|
35
|
+
match cli.cmd {
|
|
36
|
+
Commands::Init(a) => commands::init::run(a).await,
|
|
37
|
+
Commands::Deploy(a) => commands::deploy::run(a).await,
|
|
38
|
+
Commands::Status(a) => commands::status::run(a).await,
|
|
39
|
+
Commands::RequestProduction(a) => commands::request_prod::run(a).await,
|
|
40
|
+
Commands::Dns(a) => commands::dns::run(a).await,
|
|
41
|
+
Commands::Keys(a) => commands::keys::run(a).await,
|
|
42
|
+
Commands::Alias(a) => commands::alias::run(a).await,
|
|
43
|
+
Commands::Destroy(a) => commands::deploy::destroy(a).await,
|
|
44
|
+
}
|
|
45
|
+
}
|