1#[cfg(test)]
16mod tests;
17
18use std::io::Cursor;
19use std::path::Path;
20
21use crate::entry_point::config_file::collect_settings;
22use crate::entry_point::Config;
23
24#[derive(Debug, Default, Clone, PartialEq)]
26pub struct ConfigReport {
27 pub errors: Vec<String>,
29 pub warnings: Vec<String>,
31}
32
33impl ConfigReport {
34 pub fn error(&mut self, message: impl Into<String>) {
35 self.errors.push(message.into());
36 }
37
38 pub fn warn(&mut self, message: impl Into<String>) {
39 self.warnings.push(message.into());
40 }
41
42 pub fn is_ok(&self) -> bool {
44 self.errors.is_empty()
45 }
46
47 pub fn render_json(&self, path: &str) -> String {
50 fn escaped(s: &str) -> String {
51 let mut out = String::with_capacity(s.len() + 2);
52 for c in s.chars() {
53 match c {
54 '"' => out.push_str("\\\""),
55 '\\' => out.push_str("\\\\"),
56 '\n' => out.push_str("\\n"),
57 '\r' => out.push_str("\\r"),
58 '\t' => out.push_str("\\t"),
59 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
60 c => out.push(c),
61 }
62 }
63 out
64 }
65 let list = |items: &[String]| {
66 let inner: Vec<String> = items.iter().map(|i| format!("\"{}\"", escaped(i))).collect();
67 format!("[{}]", inner.join(","))
68 };
69 format!(
70 "{{\"path\":\"{}\",\"ok\":{},\"errors\":{},\"warnings\":{}}}",
71 escaped(path),
72 self.is_ok(),
73 list(&self.errors),
74 list(&self.warnings),
75 )
76 }
77
78 pub fn render(&self, path: &str) -> String {
80 let mut out = String::new();
81 for e in &self.errors {
82 out.push_str(&format!("error: {e}\n"));
83 }
84 for w in &self.warnings {
85 out.push_str(&format!("warning: {w}\n"));
86 }
87 if self.is_ok() {
88 out.push_str(&format!("{path}: configuration OK"));
89 if !self.warnings.is_empty() {
90 out.push_str(&format!(" ({} warning{})", self.warnings.len(), plural(self.warnings.len())));
91 }
92 } else {
93 out.push_str(&format!(
94 "{path}: {} error{}, {} warning{}",
95 self.errors.len(), plural(self.errors.len()),
96 self.warnings.len(), plural(self.warnings.len()),
97 ));
98 }
99 out.push('\n');
100 out
101 }
102}
103
104fn plural(n: usize) -> &'static str {
105 if n == 1 { "" } else { "s" }
106}
107
108pub fn check_target_from_args(args: &[String]) -> Option<String> {
112 let mut iter = args.iter().skip(1).peekable();
113 while let Some(arg) = iter.next() {
114 if let Some(path) = arg.strip_prefix("--check=") {
115 return Some(path.to_string());
116 }
117 if arg == "--check" {
118 return Some(match iter.peek() {
119 Some(next) if !next.starts_with('-') => next.to_string(),
120 _ => default_config_path(),
121 });
122 }
123 }
124 None
125}
126
127pub fn wants_json_output(args: &[String]) -> bool {
129 args.iter().any(|a| a == "--format=json")
130}
131
132pub fn default_config_path() -> String {
134 std::env::var("RWS_CONFIG_FILE")
135 .ok()
136 .filter(|p| !p.trim().is_empty())
137 .unwrap_or_else(|| "rws.config.toml".to_string())
138}
139
140pub fn check_file(path: &str) -> ConfigReport {
142 match std::fs::read_to_string(path) {
143 Ok(content) => check_str(&content),
144 Err(e) => {
145 let mut report = ConfigReport::default();
146 report.error(format!("cannot read {path}: {e}"));
147 report
148 }
149 }
150}
151
152thread_local! {
153 static OFFLINE: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
154}
155
156#[doc(hidden)]
159pub fn offline() -> bool {
160 OFFLINE.with(|o| o.get())
161}
162
163pub fn check_str_offline(content: &str) -> ConfigReport {
170 struct Reset;
171 impl Drop for Reset {
172 fn drop(&mut self) {
173 OFFLINE.with(|o| o.set(false));
174 }
175 }
176 OFFLINE.with(|o| o.set(true));
177 let _reset = Reset;
178 check_str(content)
179}
180
181pub fn check_str(content: &str) -> ConfigReport {
185 let mut report = ConfigReport::default();
186 if content.contains('\0') {
187 report.error("the file contains a NUL byte");
188 return report;
189 }
190
191 match collect_settings(Cursor::new(content.as_bytes()), String::new()) {
192 Ok(resolved) => {
193 for key in &resolved.unknown {
194 report.error(format!("unknown setting '{key}' — ignored"));
195 }
196 check_settings(&resolved.settings, &mut report);
197 }
198 Err(e) => report.error(format!("unable to parse: {e}")),
199 }
200
201 #[cfg(not(target_arch = "wasm32"))]
202 if content.contains("[[route]]") || content.contains("[[upstream]]")
203 || content.contains("[[tcp_proxy]]") || content.contains("[[udp_proxy]]") || content.contains("[[ws_proxy]]")
204 {
205 crate::proxy_config::validate::check_unknown_keys(content, &mut report);
206 crate::proxy_config::ProxyConfig::from_str(content).validate(&mut report);
207 } else if content.lines().any(|l| l.trim().starts_with("[middleware")) {
208 report.warn("[middleware] only applies in proxy mode (a file with [[route]] or [[upstream]] sections)");
209 }
210
211 report
212}
213
214fn check_settings(settings: &[(String, String)], report: &mut ConfigReport) {
216 let get = |name: &str| settings.iter().rev().find(|(n, _)| n == name).map(|(_, v)| v.trim());
217
218 for (name, value) in settings {
219 let value = value.trim();
220 let key = setting_key(name);
221 match name.as_str() {
222 Config::RWS_CONFIG_PORT | Config::RWS_CONFIG_HTTP_REDIRECT_PORT | Config::RWS_CONFIG_ACME_CHALLENGE_PORT => {
223 if !(name == Config::RWS_CONFIG_HTTP_REDIRECT_PORT && value.is_empty())
224 && !value.parse::<u16>().is_ok_and(|p| p > 0)
225 {
226 report.error(format!("{key} = {value:?}: not a port number (1-65535)"));
227 }
228 }
229 Config::RWS_CONFIG_IP => {
230 if value.parse::<std::net::IpAddr>().is_err() {
231 report.error(format!("{key} = {value:?}: not an IP address (use 0.0.0.0 or :: for all interfaces)"));
232 }
233 }
234 Config::RWS_CONFIG_THREAD_COUNT => {
235 if !value.parse::<usize>().is_ok_and(|n| n > 0) {
236 report.error(format!("{key} = {value:?}: not a positive number"));
237 }
238 }
239 Config::RWS_CONFIG_REQUEST_ALLOCATION_SIZE_IN_BYTES => {
240 if !value.parse::<u64>().is_ok_and(|n| n > 0) {
241 report.error(format!("{key} = {value:?}: not a positive number"));
242 }
243 }
244 "RWS_CONFIG_MAX_BODY_SIZE_IN_BYTES" | "RWS_CONFIG_OPEN_FILE_CACHE_SECS" | "RWS_CONFIG_RATE_LIMIT_MAX_REQUESTS" | "RWS_CONFIG_RATE_LIMIT_WINDOW_SECS"
245 | "RWS_CONFIG_ACME_RENEW_BEFORE_DAYS" | "RWS_CONFIG_ACME_RENEW_CHECK_INTERVAL_SECS"
246 | "RWS_CONFIG_CIRCUIT_BREAKER_FAILURE_THRESHOLD" | "RWS_CONFIG_CIRCUIT_BREAKER_MAX_HALF_OPEN_PROBES"
247 | "RWS_CONFIG_CIRCUIT_BREAKER_RECOVERY_SECS" | Config::RWS_CONFIG_CORS_MAX_AGE => {
248 if value.parse::<u64>().is_err() {
249 report.error(format!("{key} = {value:?}: not a whole number"));
250 }
251 }
252 Config::RWS_CONFIG_CORS_ALLOW_ALL | Config::RWS_CONFIG_CORS_ALLOW_CREDENTIALS | Config::RWS_CONFIG_ACME_STAGING
253 | "RWS_CONFIG_CLIENT_HINTS" | "RWS_CONFIG_OPEN_FILE_CACHE_STRICT"
254 | "RWS_CONFIG_SECURITY_HEADERS" | "RWS_CONFIG_NANOS_HEADERS" | "RWS_CONFIG_SERVE_DOTFILES" | "RWS_CONFIG_WATCH"
255 | "RWS_CONFIG_PROXY_PROTOCOL" | "RWS_CONFIG_GATEWAY_API_ENABLED" => {
256 if !matches!(value.to_ascii_lowercase().as_str(), "true" | "false") {
257 report.error(format!("{key} = {value:?}: expected true or false"));
258 }
259 }
260 "RWS_CONFIG_GATEWAY_API_MODE" => {
261 if !matches!(value.to_ascii_lowercase().as_str(), "shared" | "provisioned") {
262 report.error(format!("{key} = {value:?}: expected shared or provisioned"));
263 }
264 }
265 "RWS_CONFIG_ACCESS_LOG_MAX_SIZE" | "RWS_CONFIG_ACCESS_LOG_KEEP" => {
266 if crate::disk_guard::parse_size(value).is_none() {
267 report.error(format!("{key} = {value:?}: expected a number of bytes or files (e.g. 104857600 or 100M)"));
268 }
269 }
270 "RWS_CONFIG_RUNTIME" => {
271 if !matches!(value.to_ascii_lowercase().as_str(), "shared" | "per-core") {
272 report.error(format!("{key} = {value:?}: expected \"shared\" or \"per-core\""));
273 }
274 }
275 "RWS_CONFIG_LOG_FORMAT" => {
276 if !matches!(value.to_ascii_lowercase().as_str(), "combined" | "json" | "off" | "none") {
277 report.error(format!("{key} = {value:?}: expected \"combined\", \"json\" or \"off\""));
278 }
279 }
280 "RWS_CONFIG_TLS_CLIENT_CA_FILE" | Config::RWS_CONFIG_ACME_CA_FILE => {
281 if !offline() && !value.is_empty() && !Path::new(value).is_file() {
282 report.error(format!("{key} = {value:?}: file not found"));
283 }
284 }
285 #[cfg(feature = "http2")]
286 "RWS_CONFIG_TLS_CLIENT_CRL_FILE" => {
287 if !offline() && !value.is_empty() {
288 if let Err(e) = crate::tls::load_crls(value) {
289 report.error(format!("{key}: {e}"));
290 }
291 }
292 }
293 "RWS_CONFIG_TRUSTED_PROXIES" => {
294 if let Err(e) = crate::ip_filter::TrustedProxies::parse(value) {
295 report.error(format!("{key}: {e}"));
296 } else if value.trim() == "*" {
297 report.warn(format!("{key} = \"*\": every client can set X-Forwarded-Proto/Host and Forwarded (claim https, another host); list your load balancers instead"));
298 }
299 }
300 "RWS_CONFIG_SHUTDOWN_DELAY_SECS" => {
301 if !value.trim().is_empty() && value.trim().parse::<u64>().is_err() {
302 report.error(format!("{key} = {value:?}: expected a whole number of seconds"));
303 }
304 }
305 "RWS_CONFIG_METRICS_ALLOW" => {
306 if let Err(e) = crate::ip_filter::TrustedProxies::parse(value) {
307 report.error(format!("{key}: {}", e.replace("trusted_proxies: ", "")));
308 } else if value.trim() == "*" {
309 report.warn(format!("{key} = \"*\": /metrics (upstream names, backend addresses, traffic) is served to anyone"));
310 }
311 }
312 "RWS_CONFIG_CLIENT_IP" => {
313 let v = value.trim();
314 if !v.is_empty() && !v.eq_ignore_ascii_case("forwarded") && !v.eq_ignore_ascii_case("peer") {
315 report.error(format!("{key} = {value:?}: expected \"forwarded\" (the client from a trusted proxy's X-Forwarded-For) or \"peer\""));
316 }
317 }
318 "RWS_CONFIG_TLS" => {
319 if !value.is_empty() && !value.eq_ignore_ascii_case("internal") {
320 report.error(format!("{key} = {value:?}: expected \"internal\" (or leave it out)"));
321 }
322 }
323 "RWS_CONFIG_TEMPLATE_DIR" => {
324 if !offline() && !value.is_empty() && !Path::new(value).is_dir() {
325 report.warn(format!("{key} = {value:?}: directory not found"));
326 }
327 }
328 _ => {}
329 }
330 }
331
332 #[cfg(not(target_arch = "wasm32"))]
333 crate::admin::validate(&|name| get(name).map(str::to_string), report);
334
335 let acme_domains = get(Config::RWS_CONFIG_ACME_DOMAINS).unwrap_or("");
337 let acme = !acme_domains.is_empty();
338 let cert = get(Config::RWS_CONFIG_TLS_CERT_FILE).unwrap_or("");
339 let key = get(Config::RWS_CONFIG_TLS_KEY_FILE).unwrap_or("");
340 match (cert.is_empty(), key.is_empty()) {
341 (false, true) => report.error("tls_cert_file is set but tls_key_file is not"),
342 (true, false) => report.error("tls_key_file is set but tls_cert_file is not"),
343 (false, false) if !acme => check_tls_pair("tls_cert_file / tls_key_file", cert, key, report),
344 _ => {}
345 }
346 if let Some(fallback) = get(Config::RWS_CONFIG_SPA_FALLBACK).filter(|v| !v.is_empty()) {
349 if !offline() && !std::path::Path::new(fallback).is_file() {
350 report.warn(format!(
351 "spa_fallback = {fallback:?}: the file does not exist in the working directory, so every client-side route would 404"
352 ));
353 }
354 }
355
356 if let Some(origins) = get(Config::RWS_CONFIG_CORS_ALLOW_ORIGINS) {
359 for origin in origins.split(',').map(str::trim).filter(|o| !o.is_empty()) {
360 if origin != "*" && !origin.starts_with("http://") && !origin.starts_with("https://") {
361 report.warn(format!(
362 "[cors] allow_origins: '{origin}' does not look like scheme://host[:port]; a browser's Origin header never matches it"
363 ));
364 } else if origin != "*" && (origin.ends_with('/') || origin.rsplit_once("://").is_some_and(|(_, rest)| rest.contains('/'))) {
365 report.warn(format!(
366 "[cors] allow_origins: '{origin}' has a path or trailing slash; Origin headers carry none, so it never matches"
367 ));
368 }
369 }
370 }
371
372 let mut seen_domains: Vec<&str> = Vec::new();
373 for i in 0.. {
374 let domain = get(&format!("RWS_CONFIG_VIRTUAL_HOST_{i}_DOMAIN"));
375 let cert = get(&format!("RWS_CONFIG_VIRTUAL_HOST_{i}_CERT_FILE"));
376 let key = get(&format!("RWS_CONFIG_VIRTUAL_HOST_{i}_KEY_FILE"));
377 if domain.is_none() && cert.is_none() && key.is_none() {
378 break;
379 }
380 let label = format!("[[virtual_host]] {}", domain.unwrap_or("(no domain)"));
381 match (domain, cert, key) {
382 (Some(d), Some(c), Some(k)) if !d.is_empty() => {
383 if seen_domains.contains(&d) {
386 report.error(format!(
387 "{label}: a second [[virtual_host]] with the same domain; only one certificate can win"
388 ));
389 } else {
390 seen_domains.push(d);
391 }
392 check_tls_pair(&label, c, k, report)
393 }
394 _ => report.error(format!("{label}: needs domain, cert_file and key_file")),
395 }
396 }
397
398 if acme {
399 #[cfg(not(feature = "acme"))]
400 report.error("[acme] is configured, but this rws binary was built without the `acme` feature — no certificate would be requested");
401 if get(Config::RWS_CONFIG_ACME_EMAIL).unwrap_or("").is_empty() {
402 report.warn("[acme] has no email: the CA can't warn you about expiring or revoked certificates");
403 }
404 let challenge = get(Config::RWS_CONFIG_ACME_CHALLENGE).map(str::trim).filter(|v| !v.is_empty()).unwrap_or("http-01").to_ascii_lowercase();
405 if !matches!(challenge.as_str(), "http-01" | "tls-alpn-01" | "dns-01" | "dns-persist-01") {
406 report.error(format!("[acme] challenge = {challenge:?}: expected http-01, tls-alpn-01, dns-01 or dns-persist-01"));
407 }
408 for domain in acme_domains.split(',').map(str::trim) {
409 let (wildcard, name) = match domain.strip_prefix("*.") {
411 Some(rest) => (true, rest),
412 None => (false, domain),
413 };
414 if name.is_empty() || name.contains('*') || name.contains('/') || name.contains(':') || !name.contains('.') {
415 report.error(format!("[acme] domains: '{domain}' is not a domain name"));
416 } else if wildcard && !challenge.starts_with("dns-") {
417 report.error(format!("[acme] domains: the wildcard '{domain}' needs challenge = \"dns-01\" or \"dns-persist-01\""));
418 }
419 }
420 let port = get(Config::RWS_CONFIG_PORT).unwrap_or("7878");
421 let challenge_port = get(Config::RWS_CONFIG_ACME_CHALLENGE_PORT).unwrap_or("80");
422 let redirect_port = get(Config::RWS_CONFIG_HTTP_REDIRECT_PORT).unwrap_or("");
423 if challenge == "http-01" && redirect_port.is_empty() {
424 report.warn(format!(
425 "[acme] without http_redirect_port: nothing answers the HTTP-01 challenge on port {challenge_port}; set http_redirect_port = {challenge_port}"
426 ));
427 }
428 if challenge == "dns-01" {
429 check_acme_dns(&get, report);
430 }
431 if challenge == "dns-persist-01" && get(Config::RWS_CONFIG_ACME_DNS_PROVIDER).is_some_and(|p| !p.trim().is_empty()) {
432 report.warn("[acme] dns_provider is ignored with dns-persist-01: you publish its record once yourself (rws prints it)");
433 }
434 let on_demand = get(Config::RWS_CONFIG_ACME_ON_DEMAND).unwrap_or("").trim().to_ascii_lowercase();
435 if !matches!(on_demand.as_str(), "" | "true" | "false") {
436 report.error(format!("[acme] on_demand = {on_demand:?}: expected true or false"));
437 } else if on_demand == "true" {
438 match get(Config::RWS_CONFIG_ACME_ON_DEMAND_ASK).map(str::trim).filter(|v| !v.is_empty()) {
439 None => report.error("[acme] on_demand needs on_demand_ask: without it anyone could make rws order certificates for any name pointed at it"),
440 Some(ask) if !(ask.starts_with("http://") || ask.starts_with("https://")) => {
441 report.error(format!("[acme] on_demand_ask = {ask:?}: expected an http:// or https:// URL"))
442 }
443 Some(_) => {}
444 }
445 if challenge.starts_with("dns-") {
446 report.error(format!("[acme] on_demand works with http-01 and tls-alpn-01, not {challenge} (it would need every customer's DNS zone)"));
447 }
448 }
449 if port != "443" {
450 report.warn(format!("[acme] with port = {port}: browsers expect HTTPS on 443 (fine if a port mapping forwards 443 here)"));
451 }
452 }
453}
454
455fn check_api_url(url: Option<&str>, report: &mut ConfigReport) {
457 if let Some(url) = url.filter(|u| !(u.starts_with("https://") || u.starts_with("http://"))) {
458 report.error(format!("[acme] dns_api_url = {url:?}: expected an http:// or https:// URL"));
459 }
460}
461
462fn check_acme_dns<'a>(get: &dyn Fn(&str) -> Option<&'a str>, report: &mut ConfigReport) {
464 let set = |name: &str| get(name).map(str::trim).filter(|v| !v.is_empty());
465 match set(Config::RWS_CONFIG_ACME_DNS_PROVIDER).map(str::to_ascii_lowercase).as_deref() {
466 Some("rfc2136") => {
467 match set(Config::RWS_CONFIG_ACME_DNS_SERVER) {
468 None => report.error("[acme] dns_provider = \"rfc2136\" needs dns_server (the primary DNS server, host[:port])"),
469 Some(server) if server.contains('/') || server.is_empty() => report.error(format!("[acme] dns_server = {server:?}: expected host[:port]")),
470 Some(_) => {}
471 }
472 match (set(Config::RWS_CONFIG_ACME_DNS_TSIG_KEY), set(Config::RWS_CONFIG_ACME_DNS_TSIG_SECRET)) {
473 (Some(_), None) | (None, Some(_)) => report.error("[acme] dns_tsig_key and dns_tsig_secret go together"),
474 (None, None) => report.warn("[acme] rfc2136 without a TSIG key: the server must accept unsigned updates from this host"),
475 (Some(_), Some(secret)) => {
476 #[cfg(feature = "acme")]
477 if crate::acme::base64_secret(secret).is_none() {
478 report.error("[acme] dns_tsig_secret is not base64");
479 }
480 #[cfg(not(feature = "acme"))]
481 let _ = secret;
482 }
483 }
484 if let Some(algorithm) = set(Config::RWS_CONFIG_ACME_DNS_TSIG_ALGORITHM) {
485 if !matches!(algorithm.to_ascii_lowercase().as_str(), "hmac-sha256" | "hmac-sha512") {
486 report.error(format!("[acme] dns_tsig_algorithm = {algorithm:?}: expected hmac-sha256 or hmac-sha512"));
487 }
488 }
489 }
490 Some("exec") => match set(Config::RWS_CONFIG_ACME_DNS_EXEC) {
491 None => report.error("[acme] dns_provider = \"exec\" needs dns_exec (the program)"),
492 Some(program) if !offline() && !Path::new(program).is_file() => report.error(format!("[acme] dns_exec = {program:?}: file not found")),
493 Some(_) => {}
494 },
495 Some("cloudflare") => {
496 let in_env = offline() || std::env::var(Config::RWS_CONFIG_ACME_DNS_API_TOKEN).is_ok_and(|v| !v.trim().is_empty());
498 if set(Config::RWS_CONFIG_ACME_DNS_API_TOKEN).is_none() && !in_env {
499 report.error("[acme] dns_provider = \"cloudflare\" needs dns_api_token (an API token with DNS edit permission; or RWS_CONFIG_ACME_DNS_API_TOKEN)");
500 }
501 check_api_url(set(Config::RWS_CONFIG_ACME_DNS_API_URL), report);
502 }
503 Some("route53") => {
504 let aws = |name: &str| offline() || std::env::var(name).is_ok_and(|v| !v.trim().is_empty());
505 if !(aws("AWS_ACCESS_KEY_ID") && aws("AWS_SECRET_ACCESS_KEY")) {
506 report.warn("[acme] dns_provider = \"route53\" reads AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY from the environment, and they aren't set here");
507 }
508 check_api_url(set(Config::RWS_CONFIG_ACME_DNS_API_URL), report);
509 }
510 Some(other) => report.error(format!("[acme] dns_provider = {other:?}: expected rfc2136, cloudflare, route53 or exec")),
511 None => report.error("[acme] challenge = \"dns-01\" needs dns_provider (rfc2136, cloudflare, route53 or exec)"),
512 }
513 if let Some(secs) = set(Config::RWS_CONFIG_ACME_DNS_PROPAGATION_SECS) {
514 if secs.parse::<u64>().is_err() {
515 report.error(format!("[acme] dns_propagation_secs = {secs:?}: not a whole number"));
516 }
517 }
518}
519
520fn setting_key(env_name: &str) -> String {
522 env_name.trim_start_matches("RWS_CONFIG_").to_ascii_lowercase()
523}
524
525fn check_tls_pair(label: &str, cert: &str, key: &str, report: &mut ConfigReport) {
526 if offline() {
527 return;
528 }
529 let mut missing = false;
530 let token_key = key.trim_start().get(..7).is_some_and(|p| p.eq_ignore_ascii_case("pkcs11:"));
531 for (what, path) in [("certificate", cert), ("key", key)] {
532 if what == "key" && token_key {
533 continue;
534 }
535 if !Path::new(path).is_file() {
536 report.error(format!("{label}: {what} file '{path}' not found"));
537 missing = true;
538 }
539 }
540 if missing {
541 return;
542 }
543 #[cfg(all(feature = "http2", not(target_arch = "wasm32")))]
544 if let Err(e) = crate::tls::create_tls_acceptor(cert, key) {
545 report.error(format!("{label}: {e}"));
546 }
547 #[cfg(not(feature = "http2"))]
548 report.warn(format!("{label}: this build has no TLS support (`http1` only), so the certificate is not used"));
549}