1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
//! Integration tests for Redis template TLS support.
//!
//! These tests generate throwaway self-signed certificates with `openssl` in a
//! temporary directory, start a TLS-enabled Redis container, and verify a
//! `redis-cli --tls` connection succeeds. They are skipped automatically when
//! Docker or `openssl` is unavailable.
#[cfg(feature = "template-redis")]
mod redis_tls_tests {
use docker_wrapper::{DockerCommand, RedisTemplate, Template, VersionCommand};
use std::path::Path;
use std::process::Command;
/// Generate a unique container name for tests.
fn test_container_name(suffix: &str) -> String {
format!("test-redis-tls-{}-{}", suffix, uuid::Uuid::new_v4())
}
/// Generate a random port for testing to avoid conflicts.
fn random_port() -> u16 {
30000 + (uuid::Uuid::new_v4().as_u128() % 10000) as u16
}
/// Returns true if Docker is available on this host.
async fn docker_available() -> bool {
VersionCommand::new().execute().await.is_ok()
}
/// Returns true if the `openssl` binary is available on this host.
fn openssl_available() -> bool {
Command::new("openssl")
.arg("version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
/// Run an `openssl` subcommand, asserting it succeeds.
fn openssl(dir: &Path, args: &[&str]) {
let output = Command::new("openssl")
.args(args)
.current_dir(dir)
.output()
.expect("failed to spawn openssl");
assert!(
output.status.success(),
"openssl {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
}
/// Generate a self-signed CA plus a server certificate signed by it, using
/// the file names the Redis templates expect (`ca.crt`, `redis.crt`,
/// `redis.key`). The mounted directory must be world-readable so the
/// container's `redis` user can read the key.
fn generate_certs(dir: &Path) {
// CA key + self-signed CA cert.
openssl(dir, &["genrsa", "-out", "ca.key", "2048"]);
openssl(
dir,
&[
"req",
"-x509",
"-new",
"-nodes",
"-key",
"ca.key",
"-sha256",
"-days",
"1",
"-subj",
"/CN=docker-wrapper-test-ca",
"-out",
"ca.crt",
],
);
// Server key + CSR + cert signed by the CA.
openssl(dir, &["genrsa", "-out", "redis.key", "2048"]);
openssl(
dir,
&[
"req",
"-new",
"-key",
"redis.key",
"-subj",
"/CN=localhost",
"-out",
"redis.csr",
],
);
openssl(
dir,
&[
"x509",
"-req",
"-in",
"redis.csr",
"-CA",
"ca.crt",
"-CAkey",
"ca.key",
"-CAcreateserial",
"-days",
"1",
"-sha256",
"-out",
"redis.crt",
],
);
// The container's redis user must be able to read the key material.
for file in ["ca.crt", "redis.crt", "redis.key"] {
let path = dir.join(file);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
.expect("set cert permissions");
}
assert!(path.exists(), "expected {} to exist", file);
}
}
/// In-container redis-cli arguments for a TLS ping against the TLS port.
fn tls_ping(port: u16) -> Vec<String> {
vec![
"redis-cli".to_string(),
"--tls".to_string(),
"-p".to_string(),
port.to_string(),
"--cacert".to_string(),
"/tls/ca.crt".to_string(),
"--cert".to_string(),
"/tls/redis.crt".to_string(),
"--key".to_string(),
"/tls/redis.key".to_string(),
"ping".to_string(),
]
}
#[tokio::test]
async fn test_redis_tls_connection() -> Result<(), Box<dyn std::error::Error>> {
if !docker_available().await {
eprintln!("Docker not available, skipping TLS test");
return Ok(());
}
if !openssl_available() {
eprintln!("openssl not available, skipping TLS test");
return Ok(());
}
let certs = tempfile::tempdir()?;
generate_certs(certs.path());
let name = test_container_name("connect");
let tls_port = random_port();
// Keep plaintext open so the template's readiness ping works, then prove
// the TLS listener accepts a redis-cli --tls connection.
let redis = RedisTemplate::new(&name)
.port(random_port())
.tls_port(tls_port)
.tls(certs.path().to_string_lossy().to_string());
let container_id = redis.start_and_wait().await?;
assert!(!container_id.is_empty());
// A TLS ping against the container TLS port (6380) must succeed.
let tls_cli = tls_ping(6380);
let cli: Vec<&str> = tls_cli.iter().map(String::as_str).collect();
let result = redis.exec(cli).await?;
assert_eq!(
result.stdout.trim(),
"PONG",
"TLS ping failed: stdout={:?} stderr={:?}",
result.stdout,
result.stderr
);
// The plaintext port is still serving in non-tls-only mode.
let plain = redis.exec(vec!["redis-cli", "ping"]).await?;
assert_eq!(plain.stdout.trim(), "PONG");
// Clean up.
redis.stop().await?;
redis.remove().await?;
Ok(())
}
#[tokio::test]
async fn test_redis_tls_only_disables_plaintext() -> Result<(), Box<dyn std::error::Error>> {
if !docker_available().await {
eprintln!("Docker not available, skipping TLS test");
return Ok(());
}
if !openssl_available() {
eprintln!("openssl not available, skipping TLS test");
return Ok(());
}
let certs = tempfile::tempdir()?;
generate_certs(certs.path());
let name = test_container_name("tls-only");
let tls_port = random_port();
// TLS-only: plaintext is disabled (--port 0). The container's own health
// check is plaintext, so it never reports healthy here; we start without
// waiting and poll the TLS listener directly instead.
let redis = RedisTemplate::new(&name)
.tls_port(tls_port)
.tls(certs.path().to_string_lossy().to_string())
.tls_only();
redis.start().await?;
// Poll the TLS listener until it answers PONG (the container is up but
// has no plaintext readiness signal).
let tls_cli = tls_ping(6380);
let mut ready = false;
for _ in 0..60 {
let cli: Vec<&str> = tls_cli.iter().map(String::as_str).collect();
if let Ok(out) = redis.exec(cli).await {
if out.stdout.trim() == "PONG" {
ready = true;
break;
}
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
assert!(ready, "TLS-only Redis did not answer a TLS ping in time");
// Plaintext must be refused: a plaintext redis-cli ping against the
// disabled port either fails the exec (connection refused) or, at most,
// never returns PONG. Both outcomes prove plaintext is closed.
let plain = redis.exec(vec!["redis-cli", "-p", "6379", "ping"]).await;
match plain {
Ok(out) => assert_ne!(
out.stdout.trim(),
"PONG",
"plaintext port should be disabled in TLS-only mode, got: {:?}",
out.stdout
),
Err(_) => {
// Connection refused on the plaintext port is the expected
// result in TLS-only mode.
}
}
// Clean up.
redis.stop().await?;
redis.remove().await?;
Ok(())
}
}