Compare commits

..

5 commits

Author SHA1 Message Date
58bb606879
feat: serve requests on auto-assigned default deployment hostnames
TODO: serving custom domain requests are not yet implemented
2022-11-10 17:36:01 +05:30
ed68b4570c
feat: auto assign default deployment hostnames using crate::subdomains
utils
2022-11-10 17:35:48 +05:30
30be3a293d
feat: use settings.page.base_domain to generate default deployment hostname 2022-11-10 17:34:21 +05:30
dd38dd05d1
feat: add base_path to settings to specify deploy host name.
DESCRIPTION
    Each deployment should have a default hostname before a custom
    domain can be assigned. Therefore, this domain must be in control of
    the Librepages system (Librepages/conductor, namely)

SECURITY
    base_domain must be different from the domains hosting confidential
    information (authentication systems, PII data, etc.) to make use of
    browser domain sandboxing safety. If Librepages deployment is open
    to the public, unaudited, third-party content may be hosted in this
    domain, so it is very important that this domain shouldn't be used
    for critical infrastructure dealing with confidential information.
2022-11-10 17:19:35 +05:30
344cc85935
feat: construct random subdomains from wordlists.
SUMMARY
    Using data 1) and approach 2) mentioned here[0]

[0]: #5 (comment)
2022-11-10 17:02:41 +05:30
9 changed files with 997 additions and 56 deletions

View file

@ -19,8 +19,7 @@ cookie_secret = "94b2b2732626fdb7736229a7c777cb451e6304c147c4549f30"
[page] [page]
base_path = "/tmp/librepages-defualt-config/" base_path = "/tmp/librepages-defualt-config/"
base_domain = "librepages.test" # domain where customer pages will be deployed.
[database] [database]
# This section deals with the database location and how to access it # This section deals with the database location and how to access it

View file

@ -22,6 +22,8 @@ use crate::ctx::Ctx;
use crate::db::Site; use crate::db::Site;
use crate::errors::*; use crate::errors::*;
use crate::page::Page; use crate::page::Page;
use crate::settings::Settings;
use crate::subdomains::get_random_subdomain;
use crate::utils::get_random; use crate::utils::get_random;
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] #[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
@ -29,30 +31,30 @@ use crate::utils::get_random;
pub struct AddSite { pub struct AddSite {
pub repo_url: String, pub repo_url: String,
pub branch: String, pub branch: String,
pub hostname: String,
pub owner: String, pub owner: String,
} }
impl AddSite { impl AddSite {
fn to_site(self) -> Site { fn to_site(self, s: &Settings) -> Site {
let site_secret = get_random(32); let site_secret = get_random(32);
let hostname = get_random_subdomain(s);
Site { Site {
site_secret, site_secret,
repo_url: self.repo_url, repo_url: self.repo_url,
branch: self.branch, branch: self.branch,
hostname: self.hostname, hostname,
owner: self.owner, owner: self.owner,
} }
} }
} }
impl Ctx { impl Ctx {
pub async fn add_site(&self, site: AddSite) -> ServiceResult<()> { pub async fn add_site(&self, site: AddSite) -> ServiceResult<Page> {
let db_site = site.to_site(); let db_site = site.to_site(&self.settings);
self.db.add_site(&db_site).await?; self.db.add_site(&db_site).await?;
let page = Page::from_site(&self.settings, db_site); let page = Page::from_site(&self.settings, db_site);
page.update(&page.branch)?; page.update(&page.branch)?;
Ok(()) Ok(page)
} }
pub async fn update_site(&self, secret: &str, branch: Option<String>) -> ServiceResult<()> { pub async fn update_site(&self, secret: &str, branch: Option<String>) -> ServiceResult<()> {

View file

@ -117,14 +117,11 @@ mod tests {
let (_dir, ctx) = tests::get_ctx().await; let (_dir, ctx) = tests::get_ctx().await;
let _ = ctx.delete_user(NAME, PASSWORD).await; let _ = ctx.delete_user(NAME, PASSWORD).await;
let (_, _signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await; let (_, _signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let hostname = ctx.get_test_hostname(NAME); let page = ctx.add_test_site(NAME.into()).await;
ctx.add_test_site(NAME.into(), hostname.clone()).await;
let app = get_app!(ctx).await; let app = get_app!(ctx).await;
let page = ctx.db.get_site(NAME, &hostname).await.unwrap();
let mut payload = DeployEvent { let mut payload = DeployEvent {
secret: page.site_secret.clone(), secret: page.secret.clone(),
branch: page.branch.clone(), branch: page.branch.clone(),
}; };
@ -154,13 +151,11 @@ mod tests {
let (_dir, ctx) = tests::get_ctx().await; let (_dir, ctx) = tests::get_ctx().await;
let _ = ctx.delete_user(NAME, PASSWORD).await; let _ = ctx.delete_user(NAME, PASSWORD).await;
let (_, _signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await; let (_, _signin_resp) = ctx.register_and_signin(NAME, EMAIL, PASSWORD).await;
let hostname = ctx.get_test_hostname(NAME); let page = ctx.add_test_site(NAME.into()).await;
ctx.add_test_site(NAME.into(), hostname.clone()).await;
let app = get_app!(ctx).await; let app = get_app!(ctx).await;
let page = ctx.db.get_site(NAME, &hostname).await.unwrap();
let mut payload = DeploySecret { let mut payload = DeploySecret {
secret: page.site_secret.clone(), secret: page.secret.clone(),
}; };
let resp = test::call_service( let resp = test::call_service(
@ -173,7 +168,7 @@ mod tests {
let response: DeployInfo = actix_web::test::read_body_json(resp).await; let response: DeployInfo = actix_web::test::read_body_json(resp).await;
assert_eq!(response.head, page.branch); assert_eq!(response.head, page.branch);
assert_eq!(response.remote, page.repo_url); assert_eq!(response.remote, page.repo);
payload.secret = page.branch.clone(); payload.secret = page.branch.clone();

View file

@ -38,6 +38,7 @@ mod preview;
mod serve; mod serve;
mod settings; mod settings;
mod static_assets; mod static_assets;
mod subdomains;
#[cfg(test)] #[cfg(test)]
mod tests; mod tests;
mod utils; mod utils;

View file

@ -25,7 +25,7 @@ pub struct Preview<'a> {
impl<'a> Preview<'a> { impl<'a> Preview<'a> {
pub fn new(ctx: &'a AppCtx) -> Self { pub fn new(ctx: &'a AppCtx) -> Self {
Self { Self {
base: &ctx.settings.server.domain, base: &ctx.settings.page.base_domain,
delimiter: ".", delimiter: ".",
prefix: "deploy-preview-", prefix: "deploy-preview-",
} }

View file

@ -41,19 +41,17 @@ async fn index(req: HttpRequest, ctx: AppCtx) -> ServiceResult<impl Responder> {
host = host.split(':').next().unwrap(); host = host.split(':').next().unwrap();
} }
// serve meta page
if host == ctx.settings.server.domain || host == "localhost" { if host == ctx.settings.server.domain || host == "localhost" {
return Ok(HttpResponse::Ok() return Ok(HttpResponse::Ok()
.content_type(ContentType::html()) .content_type(ContentType::html())
.body("Welcome to Librepages!")); .body("Welcome to Librepages!"));
} }
if host.contains(&ctx.settings.server.domain) { // serve default hostname content
if host.contains(&ctx.settings.page.base_domain) {
let extractor = crate::preview::Preview::new(&ctx); let extractor = crate::preview::Preview::new(&ctx);
if let Some(preview_branch) = extractor.extract(host) { if let Some(preview_branch) = extractor.extract(host) {
unimplemented!(
"map a local subdomain on settings.server.domain and use it to fetch page"
);
let res = if ctx.db.hostname_exists(&host).await? { let res = if ctx.db.hostname_exists(&host).await? {
let path = crate::utils::get_website_path(&ctx.settings, &host); let path = crate::utils::get_website_path(&ctx.settings, &host);
let content = let content =
@ -70,9 +68,11 @@ async fn index(req: HttpRequest, ctx: AppCtx) -> ServiceResult<impl Responder> {
} else { } else {
Err(ServiceError::WebsiteNotFound) Err(ServiceError::WebsiteNotFound)
}; };
return res;
} }
} }
// TODO: custom domains.
if ctx.db.hostname_exists(host).await? { if ctx.db.hostname_exists(host).await? {
let path = crate::utils::get_website_path(&ctx.settings, &host); let path = crate::utils::get_website_path(&ctx.settings, &host);
let content = crate::git::read_file(&path, req.uri().path())?; let content = crate::git::read_file(&path, req.uri().path())?;

View file

@ -89,6 +89,7 @@ pub struct Settings {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PageConfig { pub struct PageConfig {
pub base_path: String, pub base_path: String,
pub base_domain: String,
} }
#[cfg(not(tarpaulin_include))] #[cfg(not(tarpaulin_include))]
@ -181,31 +182,7 @@ impl Settings {
} }
} }
// create_dir_util(Path::new(&page.path));
create_dir_util(Path::new(&self.page.base_path)); create_dir_util(Path::new(&self.page.base_path));
// for (index, page) in self.pages.iter().enumerate() {
// Url::parse(&page.repo).unwrap();
//
// for (index2, page2) in self.pages.iter().enumerate() {
// if index2 == index {
// continue;
// }
// if page.secret == page2.secret {
// error!("{}", ServiceError::SecretTaken(page.clone(), page2.clone()));
// } else if page.repo == page2.repo {
// error!(
// "{}",
// ServiceError::DuplicateRepositoryURL(page.clone(), page2.clone(),)
// );
// } else if page.path == page2.path {
// error!("{}", ServiceError::PathTaken(page.clone(), page2.clone()));
// }
// }
// if let Err(e) = page.update(&page.branch) {
// error!("{e}");
// }
// }
} }
#[cfg(not(tarpaulin_include))] #[cfg(not(tarpaulin_include))]

972
src/subdomains.rs Normal file
View file

@ -0,0 +1,972 @@
/*
* Copyright (C) 2022 Aravinth Manivannan <realaravinth@batsense.net>
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
use crate::settings::Settings;
// source: https://www.randomlists.com/data/nouns.json
const LEN: usize = 876;
const WORDLIST: [&str; LEN] = [
"account",
"achiever",
"acoustics",
"act",
"action",
"activity",
"actor",
"addition",
"adjustment",
"advertisement",
"advice",
"aftermath",
"afternoon",
"afterthought",
"agreement",
"air",
"airplane",
"airport",
"alarm",
"amount",
"amusement",
"anger",
"angle",
"animal",
"ants",
"apparatus",
"apparel",
"appliance",
"approval",
"arch",
"argument",
"arithmetic",
"arm",
"army",
"art",
"attack",
"attraction",
"aunt",
"authority",
"babies",
"baby",
"back",
"badge",
"bag",
"bait",
"balance",
"ball",
"base",
"baseball",
"basin",
"basket",
"basketball",
"bat",
"bath",
"battle",
"bead",
"bear",
"bed",
"bedroom",
"beds",
"bee",
"beef",
"beginner",
"behavior",
"belief",
"believe",
"bell",
"bells",
"berry",
"bike",
"bikes",
"bird",
"birds",
"birth",
"birthday",
"bit",
"bite",
"blade",
"blood",
"blow",
"board",
"boat",
"bomb",
"bone",
"book",
"books",
"boot",
"border",
"bottle",
"boundary",
"box",
"boy",
"brake",
"branch",
"brass",
"breath",
"brick",
"bridge",
"brother",
"bubble",
"bucket",
"building",
"bulb",
"burst",
"bushes",
"business",
"butter",
"button",
"cabbage",
"cable",
"cactus",
"cake",
"cakes",
"calculator",
"calendar",
"camera",
"camp",
"can",
"cannon",
"canvas",
"cap",
"caption",
"car",
"card",
"care",
"carpenter",
"carriage",
"cars",
"cart",
"cast",
"cat",
"cats",
"cattle",
"cause",
"cave",
"celery",
"cellar",
"cemetery",
"cent",
"chalk",
"chance",
"change",
"channel",
"cheese",
"cherries",
"cherry",
"chess",
"chicken",
"chickens",
"children",
"chin",
"church",
"circle",
"clam",
"class",
"cloth",
"clover",
"club",
"coach",
"coal",
"coast",
"coat",
"cobweb",
"coil",
"collar",
"color",
"committee",
"company",
"comparison",
"competition",
"condition",
"connection",
"control",
"cook",
"copper",
"corn",
"cough",
"country",
"cover",
"cow",
"cows",
"crack",
"cracker",
"crate",
"crayon",
"cream",
"creator",
"creature",
"credit",
"crib",
"crime",
"crook",
"crow",
"crowd",
"crown",
"cub",
"cup",
"current",
"curtain",
"curve",
"cushion",
"dad",
"daughter",
"day",
"death",
"debt",
"decision",
"deer",
"degree",
"design",
"desire",
"desk",
"destruction",
"detail",
"development",
"digestion",
"dime",
"dinner",
"dinosaurs",
"direction",
"dirt",
"discovery",
"discussion",
"distance",
"distribution",
"division",
"dock",
"doctor",
"dog",
"dogs",
"doll",
"dolls",
"donkey",
"door",
"downtown",
"drain",
"drawer",
"dress",
"drink",
"driving",
"drop",
"duck",
"ducks",
"dust",
"ear",
"earth",
"earthquake",
"edge",
"education",
"effect",
"egg",
"eggnog",
"eggs",
"elbow",
"end",
"engine",
"error",
"event",
"example",
"exchange",
"existence",
"expansion",
"experience",
"expert",
"eye",
"eyes",
"face",
"fact",
"fairies",
"fall",
"fang",
"farm",
"fear",
"feeling",
"field",
"finger",
"fire",
"fireman",
"fish",
"flag",
"flame",
"flavor",
"flesh",
"flight",
"flock",
"floor",
"flower",
"flowers",
"fly",
"fog",
"fold",
"food",
"foot",
"force",
"fork",
"form",
"fowl",
"frame",
"friction",
"friend",
"friends",
"frog",
"frogs",
"front",
"fruit",
"fuel",
"furniture",
"gate",
"geese",
"ghost",
"giants",
"giraffe",
"girl",
"girls",
"glass",
"glove",
"gold",
"government",
"governor",
"grade",
"grain",
"grandfather",
"grandmother",
"grape",
"grass",
"grip",
"ground",
"group",
"growth",
"guide",
"guitar",
"gun",
"hair",
"haircut",
"hall",
"hammer",
"hand",
"hands",
"harbor",
"harmony",
"hat",
"hate",
"head",
"health",
"heat",
"hill",
"history",
"hobbies",
"hole",
"holiday",
"home",
"honey",
"hook",
"hope",
"horn",
"horse",
"horses",
"hose",
"hospital",
"hot",
"hour",
"house",
"houses",
"humor",
"hydrant",
"ice",
"icicle",
"idea",
"impulse",
"income",
"increase",
"industry",
"ink",
"insect",
"instrument",
"insurance",
"interest",
"invention",
"iron",
"island",
"jail",
"jam",
"jar",
"jeans",
"jelly",
"jellyfish",
"jewel",
"join",
"judge",
"juice",
"jump",
"kettle",
"key",
"kick",
"kiss",
"kittens",
"kitty",
"knee",
"knife",
"knot",
"knowledge",
"laborer",
"lace",
"ladybug",
"lake",
"lamp",
"land",
"language",
"laugh",
"leather",
"leg",
"legs",
"letter",
"letters",
"lettuce",
"level",
"library",
"limit",
"line",
"linen",
"lip",
"liquid",
"loaf",
"lock",
"locket",
"look",
"loss",
"love",
"low",
"lumber",
"lunch",
"lunchroom",
"machine",
"magic",
"maid",
"mailbox",
"man",
"marble",
"mark",
"market",
"mask",
"mass",
"match",
"meal",
"measure",
"meat",
"meeting",
"memory",
"men",
"metal",
"mice",
"middle",
"milk",
"mind",
"mine",
"minister",
"mint",
"minute",
"mist",
"mitten",
"mom",
"money",
"monkey",
"month",
"moon",
"morning",
"mother",
"motion",
"mountain",
"mouth",
"move",
"muscle",
"name",
"nation",
"neck",
"need",
"needle",
"nerve",
"nest",
"night",
"noise",
"north",
"nose",
"note",
"notebook",
"number",
"nut",
"oatmeal",
"observation",
"ocean",
"offer",
"office",
"oil",
"orange",
"oranges",
"order",
"oven",
"page",
"pail",
"pan",
"pancake",
"paper",
"parcel",
"part",
"partner",
"party",
"passenger",
"payment",
"peace",
"pear",
"pen",
"pencil",
"person",
"pest",
"pet",
"pets",
"pickle",
"picture",
"pie",
"pies",
"pig",
"pigs",
"pin",
"pipe",
"pizzas",
"place",
"plane",
"planes",
"plant",
"plantation",
"plants",
"plastic",
"plate",
"play",
"playground",
"pleasure",
"plot",
"plough",
"pocket",
"point",
"poison",
"pollution",
"popcorn",
"porter",
"position",
"pot",
"potato",
"powder",
"power",
"price",
"produce",
"profit",
"property",
"prose",
"protest",
"pull",
"pump",
"punishment",
"purpose",
"push",
"quarter",
"quartz",
"queen",
"question",
"quicksand",
"quiet",
"quill",
"quilt",
"quince",
"quiver",
"rabbit",
"rabbits",
"rail",
"railway",
"rain",
"rainstorm",
"rake",
"range",
"rat",
"rate",
"ray",
"reaction",
"reading",
"reason",
"receipt",
"recess",
"record",
"regret",
"relation",
"religion",
"representative",
"request",
"respect",
"rest",
"reward",
"rhythm",
"rice",
"riddle",
"rifle",
"ring",
"rings",
"river",
"road",
"robin",
"rock",
"rod",
"roll",
"roof",
"room",
"root",
"rose",
"route",
"rub",
"rule",
"run",
"sack",
"sail",
"salt",
"sand",
"scale",
"scarecrow",
"scarf",
"scene",
"scent",
"school",
"science",
"scissors",
"screw",
"sea",
"seashore",
"seat",
"secretary",
"seed",
"selection",
"self",
"sense",
"servant",
"shade",
"shake",
"shame",
"shape",
"sheep",
"sheet",
"shelf",
"ship",
"shirt",
"shock",
"shoe",
"shoes",
"shop",
"show",
"side",
"sidewalk",
"sign",
"silk",
"silver",
"sink",
"sister",
"sisters",
"size",
"skate",
"skin",
"skirt",
"sky",
"slave",
"sleep",
"sleet",
"slip",
"slope",
"smash",
"smell",
"smile",
"smoke",
"snail",
"snails",
"snake",
"snakes",
"sneeze",
"snow",
"soap",
"society",
"sock",
"soda",
"sofa",
"son",
"song",
"songs",
"sort",
"sound",
"soup",
"space",
"spade",
"spark",
"spiders",
"sponge",
"spoon",
"spot",
"spring",
"spy",
"square",
"squirrel",
"stage",
"stamp",
"star",
"start",
"statement",
"station",
"steam",
"steel",
"stem",
"step",
"stew",
"stick",
"sticks",
"stitch",
"stocking",
"stomach",
"stone",
"stop",
"store",
"story",
"stove",
"stranger",
"straw",
"stream",
"street",
"stretch",
"string",
"structure",
"substance",
"sugar",
"suggestion",
"suit",
"summer",
"sun",
"support",
"surprise",
"sweater",
"swim",
"swing",
"system",
"table",
"tail",
"talk",
"tank",
"taste",
"tax",
"teaching",
"team",
"teeth",
"temper",
"tendency",
"tent",
"territory",
"test",
"texture",
"theory",
"thing",
"things",
"thought",
"thread",
"thrill",
"throat",
"throne",
"thumb",
"thunder",
"ticket",
"tiger",
"time",
"tin",
"title",
"toad",
"toe",
"toes",
"tomatoes",
"tongue",
"tooth",
"toothbrush",
"toothpaste",
"top",
"touch",
"town",
"toy",
"toys",
"trade",
"trail",
"train",
"trains",
"tramp",
"transport",
"tray",
"treatment",
"tree",
"trees",
"trick",
"trip",
"trouble",
"trousers",
"truck",
"trucks",
"tub",
"turkey",
"turn",
"twig",
"twist",
"umbrella",
"uncle",
"underwear",
"unit",
"use",
"vacation",
"value",
"van",
"vase",
"vegetable",
"veil",
"vein",
"verse",
"vessel",
"vest",
"view",
"visitor",
"voice",
"volcano",
"volleyball",
"voyage",
"walk",
"wall",
"war",
"wash",
"waste",
"watch",
"water",
"wave",
"waves",
"wax",
"way",
"wealth",
"weather",
"week",
"weight",
"wheel",
"whip",
"whistle",
"wilderness",
"wind",
"window",
"wine",
"wing",
"winter",
"wire",
"wish",
"woman",
"women",
"wood",
"wool",
"word",
"work",
"worm",
"wound",
"wren",
"wrench",
"wrist",
"writer",
"writing",
"yak",
"yam",
"yard",
"yarn",
"year",
"yoke",
"zebra",
"zephyr",
"zinc",
"zipper",
"zoo",
];
struct ID<'a> {
first: &'a str,
second: &'a str,
third: &'a str,
}
impl<'a> ID<'a> {
fn hostname(&self, base_domain: &str) -> String {
format!(
"{}-{}-{}.{}",
self.first, self.second, self.third, base_domain
)
}
}
fn get_random_id() -> ID<'static> {
use rand::{rngs::ThreadRng, thread_rng, Rng};
let mut rng: ThreadRng = thread_rng();
let first: usize = rng.gen_range(0..LEN);
let mut second: usize;
let mut third: usize;
loop {
second = rng.gen_range(0..LEN);
if second != first {
break;
}
}
loop {
third = rng.gen_range(0..LEN);
if third != first && second != third {
break;
}
}
let first = WORDLIST[first];
let second = WORDLIST[second];
let third = WORDLIST[third];
ID {
first,
second,
third,
}
}
pub fn get_random_subdomain(s: &Settings) -> String {
let id = get_random_id();
id.hostname(&s.page.base_domain)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_subdomains() {
// test random ID
let id = get_random_id();
assert_ne!(id.first, id.second);
assert_ne!(id.first, id.third);
assert_ne!(id.third, id.second);
// test ID::hostname
let delimiter = "foobar21312";
assert_eq!(
id.hostname(delimiter),
format!("{}-{}-{}.{delimiter}", id.first, id.second, id.third,)
);
}
}

View file

@ -29,10 +29,10 @@ use crate::ctx::api::v1::auth::{Login, Register};
use crate::ctx::api::v1::pages::AddSite; use crate::ctx::api::v1::pages::AddSite;
use crate::ctx::Ctx; use crate::ctx::Ctx;
use crate::errors::*; use crate::errors::*;
use crate::page::Page;
use crate::settings::Settings; use crate::settings::Settings;
use crate::*; use crate::*;
const HOSTNAME: &str = "example.org";
pub const REPO_URL: &str = "https://github.com/mCaptcha/website/"; pub const REPO_URL: &str = "https://github.com/mCaptcha/website/";
pub const BRANCH: &str = "gh-pages"; pub const BRANCH: &str = "gh-pages";
@ -268,17 +268,12 @@ impl Ctx {
assert_eq!(resp_err.error, format!("{}", err)); assert_eq!(resp_err.error, format!("{}", err));
} }
pub async fn add_test_site(&self, owner: String, hostname: String) { pub async fn add_test_site(&self, owner: String) -> Page {
let msg = AddSite { let msg = AddSite {
repo_url: REPO_URL.into(), repo_url: REPO_URL.into(),
branch: BRANCH.into(), branch: BRANCH.into(),
hostname,
owner, owner,
}; };
self.add_site(msg).await.unwrap(); self.add_site(msg).await.unwrap()
}
pub fn get_test_hostname(&self, unique: &str) -> String {
format!("{unique}.{HOSTNAME}")
} }
} }