feat: custom TLD virtual hosts with SNI certs, dynamic PAC, and TLD validation
CI / Build & Test (push) Successful in 3m15s

- Add isValidVhostHostname() to dashboard.js with embedded REAL_TLDS and
  REAL_SLD_TLDS blocklists; enforces 3-label minimum (two-tier TLD requirement),
  valid label characters, and blocks real public TLDs/SLDs (e.g. .com, co.uk)
- Replace hardcoded .hole.sail validation in vhost submit handler with new validator
- Update Add Virtual Host modal hint text and add inline format explanation
- Update applyPAC() in background.js to accept a tlds array, generating one
  dnsDomainIs clause per unique two-label base domain; .hole.sail always included
- Store virtualHosts in extensionState and pass derived TLD list to applyPAC at
  every getState response and retryGetStateForConnectProxy call
- Replace single upfront *.hole.sail cert in https-proxy.js with SNICallback that
  lazily generates a wildcard cert per two-label base domain on first connection;
  baseline *.hole.sail cert still pre-generated at startup
- Add chrome.permissions.request() in background.js send handler to grant host
  permissions for new TLDs dynamically after successful setVirtualHost
- Add optional_host_permissions: ["*://*/*"] and "permissions" to manifest.json
  to enable runtime host permission grants for custom TLDs
This commit is contained in:
Raven Scott
2026-02-28 20:20:28 -05:00
parent 8727809b12
commit 00d9c958ca
5 changed files with 456 additions and 32 deletions
+51 -11
View File
@@ -81,19 +81,31 @@ const DEFAULT_PROXY_PORT = 8443;
/** CONNECT proxy port (browser must send CONNECT here, not to HTTPS port). Use when host omits connectProxyPort. */ /** CONNECT proxy port (browser must send CONNECT here, not to HTTPS port). Use when host omits connectProxyPort. */
const DEFAULT_CONNECT_PROXY_PORT = 8442; const DEFAULT_CONNECT_PROXY_PORT = 8442;
function applyPAC() { /** Extract unique two-label base domains from virtualHosts array, always including hole.sail */
function getActiveTlds(virtualHosts) {
const seen = new Set(['hole.sail']);
for (const v of (virtualHosts || [])) {
if (v && v.hostname) {
const parts = v.hostname.split('.');
if (parts.length >= 3) seen.add(parts.slice(-2).join('.'));
}
}
return Array.from(seen).map(b => '.' + b);
}
function applyPAC(tlds) {
if (!browser.proxy || !browser.proxy.settings) { if (!browser.proxy || !browser.proxy.settings) {
log('applyPAC: SKIPPED - no browser.proxy.settings API'); log('applyPAC: SKIPPED - no browser.proxy.settings API');
return; return;
} }
const port = extensionState.connectProxyPort ?? DEFAULT_CONNECT_PROXY_PORT ?? extensionState.proxyPort ?? DEFAULT_PROXY_PORT; const port = extensionState.connectProxyPort ?? DEFAULT_CONNECT_PROXY_PORT ?? extensionState.proxyPort ?? DEFAULT_PROXY_PORT;
log('applyPAC: setting PAC port=', port); const activeTlds = tlds || getActiveTlds(extensionState.virtualHosts);
log('applyPAC: setting PAC port=', port, 'tlds=', activeTlds);
const pacData = `function FindProxyForURL(url, host) { const clauses = activeTlds.map(t =>
if (dnsDomainIs(host, ".hole.sail")) ` if (dnsDomainIs(host, "${t}")) return "PROXY 127.0.0.1:${port}";`
return "PROXY 127.0.0.1:${port}"; ).join('\n');
return "DIRECT"; const pacData = `function FindProxyForURL(url, host) {\n${clauses}\n return "DIRECT";\n}`;
}`;
browser.proxy.settings.set( browser.proxy.settings.set(
{ value: { mode: 'pac_script', pacScript: { data: pacData, mandatory: false } }, scope: 'regular' }, { value: { mode: 'pac_script', pacScript: { data: pacData, mandatory: false } }, scope: 'regular' },
@@ -156,7 +168,8 @@ function retryGetStateForConnectProxy(delaySeconds, attempt = 0) {
if (payload && payload.ok && payload.connectProxyPort != null) { if (payload && payload.ok && payload.connectProxyPort != null) {
extensionState.connectProxyPort = payload.connectProxyPort; extensionState.connectProxyPort = payload.connectProxyPort;
extensionState.proxyPort = payload.proxyPort ?? null; extensionState.proxyPort = payload.proxyPort ?? null;
applyPAC(); extensionState.virtualHosts = payload.virtualHosts || extensionState.virtualHosts;
applyPAC(getActiveTlds(extensionState.virtualHosts));
} else if (payload && payload.ok && attempt + 1 < maxAttempts) { } else if (payload && payload.ok && attempt + 1 < maxAttempts) {
retryGetStateForConnectProxy(delaySeconds, attempt + 1); retryGetStateForConnectProxy(delaySeconds, attempt + 1);
} }
@@ -182,11 +195,12 @@ function connect() {
if (payload && payload.ok) { if (payload && payload.ok) {
extensionState.proxyPort = payload.proxyPort ?? null; extensionState.proxyPort = payload.proxyPort ?? null;
extensionState.connectProxyPort = payload.connectProxyPort ?? null; extensionState.connectProxyPort = payload.connectProxyPort ?? null;
extensionState.virtualHosts = payload.virtualHosts || [];
// Sync notifyOnDisconnect from persisted settings // Sync notifyOnDisconnect from persisted settings
if (payload.settings && typeof payload.settings.notifyOnDisconnect === 'boolean') { if (payload.settings && typeof payload.settings.notifyOnDisconnect === 'boolean') {
notifyOnDisconnect = payload.settings.notifyOnDisconnect; notifyOnDisconnect = payload.settings.notifyOnDisconnect;
} }
applyPAC(); applyPAC(getActiveTlds(extensionState.virtualHosts));
if (extensionState.connectProxyPort == null) retryGetStateForConnectProxy(2); if (extensionState.connectProxyPort == null) retryGetStateForConnectProxy(2);
} else { } else {
applyPAC(); // Apply PAC even if getState fails - use default port applyPAC(); // Apply PAC even if getState fails - use default port
@@ -426,7 +440,32 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.action === 'send') { if (message.action === 'send') {
send(message.payload) send(message.payload)
.then((r) => reply(r)) .then((r) => {
// After a successful setVirtualHost, request host permission for any new TLD
if (
message.payload?.type === 'setVirtualHost' &&
r && r.ok &&
message.payload?.payload?.hostname
) {
const hostname = message.payload.payload.hostname;
const parts = hostname.split('.');
if (parts.length >= 3) {
const baseDomain = parts.slice(-2).join('.');
const origin = '*://*.' + baseDomain + '/*';
// Only request if not already in extensionState virtualHosts
const alreadyKnown = (extensionState.virtualHosts || []).some(v => {
const vParts = (v.hostname || '').split('.');
return vParts.length >= 3 && vParts.slice(-2).join('.') === baseDomain;
});
if (!alreadyKnown && baseDomain !== 'hole.sail') {
browser.permissions.request({ origins: [origin] }, (granted) => {
log('permissions.request for', origin, ':', granted ? 'granted' : 'denied');
});
}
}
}
reply(r);
})
.catch((err) => reply({ error: err.message })); .catch((err) => reply({ error: err.message }));
return true; // async response return true; // async response
} }
@@ -466,12 +505,13 @@ browser.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (payload && payload.ok === true && (payload.servers || payload.virtualHosts || payload.proxyPort != null || payload.connectProxyPort != null)) { if (payload && payload.ok === true && (payload.servers || payload.virtualHosts || payload.proxyPort != null || payload.connectProxyPort != null)) {
extensionState.proxyPort = payload.proxyPort ?? null; extensionState.proxyPort = payload.proxyPort ?? null;
extensionState.connectProxyPort = payload.connectProxyPort ?? null; extensionState.connectProxyPort = payload.connectProxyPort ?? null;
extensionState.virtualHosts = payload.virtualHosts || [];
if (payload.settings && typeof payload.settings.notifyOnDisconnect === 'boolean') { if (payload.settings && typeof payload.settings.notifyOnDisconnect === 'boolean') {
notifyOnDisconnect = payload.settings.notifyOnDisconnect; notifyOnDisconnect = payload.settings.notifyOnDisconnect;
} }
if (!pacConfirmedActive) { if (!pacConfirmedActive) {
log('getState: PAC not confirmed active, re-applying'); log('getState: PAC not confirmed active, re-applying');
applyPAC(); applyPAC(getActiveTlds(extensionState.virtualHosts));
} }
reply({ reply({
ok: true, ok: true,
+2 -1
View File
@@ -1580,8 +1580,9 @@
<div class="modal-body"> <div class="modal-body">
<p class="modal-desc">Assign a hostname to an hs:// tunnel URL. The host will be accessible at <code style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--cyan);">https://&lt;hostname&gt;</code> via the proxy.</p> <p class="modal-desc">Assign a hostname to an hs:// tunnel URL. The host will be accessible at <code style="font-family:'JetBrains Mono',monospace;font-size:11px;color:var(--cyan);">https://&lt;hostname&gt;</code> via the proxy.</p>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="addVhostHostname">Hostname <span class="form-hint">(must end with .hole.sail)</span></label> <label class="form-label" for="addVhostHostname">Hostname <span class="form-hint">(e.g. myapp.hole.sail or api.my.internal — no real TLDs)</span></label>
<input type="text" id="addVhostHostname" class="input mono" placeholder="myapp.hole.sail" autocomplete="off" spellcheck="false"> <input type="text" id="addVhostHostname" class="input mono" placeholder="myapp.hole.sail" autocomplete="off" spellcheck="false">
<div style="font-size:11px;color:var(--text4);margin-top:4px;line-height:1.5;">Must be <code style="font-size:10px;">host.second.tld</code> format with a private TLD (3 labels minimum). Real TLDs like <code style="font-size:10px;">.com</code>, <code style="font-size:10px;">.co.uk</code> are not allowed.</div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="addVhostHsUrl">hs:// URL</label> <label class="form-label" for="addVhostHsUrl">hs:// URL</label>
+351 -2
View File
@@ -53,6 +53,355 @@ function escapeHtml(str) {
return div.innerHTML; return div.innerHTML;
} }
// ── Virtual host TLD validator ────────────────────────────────────────────────
// Real single-label TLDs that must be blocked (compact subset of IANA list)
const REAL_TLDS = new Set([
'ac','ad','ae','af','ag','ai','al','am','ao','aq','ar','as','at','au','aw','ax','az',
'ba','bb','bd','be','bf','bg','bh','bi','bj','bm','bn','bo','br','bs','bt','bv','bw',
'by','bz','ca','cc','cd','cf','cg','ch','ci','ck','cl','cm','cn','co','cr','cu','cv',
'cw','cx','cy','cz','de','dj','dk','dm','do','dz','ec','ee','eg','er','es','et','eu',
'fi','fj','fk','fm','fo','fr','ga','gb','gd','ge','gf','gg','gh','gi','gl','gm','gn',
'gp','gq','gr','gs','gt','gu','gw','gy','hk','hm','hn','hr','ht','hu','id','ie','il',
'im','in','io','iq','ir','is','it','je','jm','jo','jp','ke','kg','kh','ki','km','kn',
'kp','kr','kw','ky','kz','la','lb','lc','li','lk','lr','ls','lt','lu','lv','ly','ma',
'mc','md','me','mg','mh','mk','ml','mm','mn','mo','mp','mq','mr','ms','mt','mu','mv',
'mw','mx','my','mz','na','nc','ne','nf','ng','ni','nl','no','np','nr','nu','nz','om',
'pa','pe','pf','pg','ph','pk','pl','pm','pn','pr','ps','pt','pw','py','qa','re','ro',
'rs','ru','rw','sa','sb','sc','sd','se','sg','sh','si','sj','sk','sl','sm','sn','so',
'sr','ss','st','su','sv','sx','sy','sz','tc','td','tf','tg','th','tj','tk','tl','tm',
'tn','to','tr','tt','tv','tw','tz','ua','ug','uk','us','uy','uz','va','vc','ve','vg',
'vi','vn','vu','wf','ws','ye','yt','za','zm','zw',
// Generic TLDs
'aaa','aarp','abb','abc','able','abogado','abudhabi','academy','accenture','accountant',
'accountants','aco','actor','ads','adult','aeg','aero','aetna','africa','agakhan','agency',
'aig','airbus','airforce','airtel','akdn','alfaromeo','alibaba','alipay','allfinanz',
'allstate','ally','alsace','alstom','amazon','americanexpress','americanfamily','amex',
'amfam','amica','amsterdam','analytics','android','anquan','anz','aol','apartments',
'app','apple','aquarelle','arab','aramco','archi','army','art','arte','asda','associates',
'athleta','auction','audi','audible','audio','auspost','author','auto','autos','avianca',
'aws','axa','azure','baby','baidu','banamex','band','bank','bar','barcelona','barclaycard',
'barclays','barefoot','bargains','baseball','basketball','bauhaus','bayern','bbc','bbt',
'bbva','bcg','bcn','beats','beauty','beer','bentley','berlin','best','bestbuy','bet',
'bible','bid','bike','bing','bingo','bio','black','blackfriday','blockbuster','blog',
'bloomberg','blue','bms','bmw','bnl','bnpparibas','boats','boehringer','bofa','bom',
'bond','boo','book','booking','bosch','bostik','boston','bot','boutique','box','bradesco',
'bridgestone','broadway','broker','brother','brussels','budapest','bugatti','build',
'builders','business','buy','buzz','bzh','cab','cafe','cal','call','calvinklein','cam',
'camera','camp','cancerresearch','canon','capetown','capital','capitalone','cards','care',
'career','careers','cars','casa','case','cash','casino','cat','catering','catholic','cba',
'cbn','cbre','cbs','center','ceo','cern','cfa','cfd','channel','charity','chase','chat',
'cheap','chintai','christmas','chrome','church','cipriani','circle','cisco','citi',
'citic','city','cityeats','claims','cleaning','click','clinic','clinique','clothing',
'cloud','club','clubmed','codes','coffee','college','cologne','com','community','company',
'compare','computer','comsec','condos','construction','consulting','contact','contractors',
'cooking','cool','coop','corsica','country','coupon','coupons','courses','credit',
'creditcard','creditunion','cricket','crown','crs','cruise','cruises','csc','cuisinella',
'cymru','cyou','dabur','dad','dance','data','date','dating','datsun','day','dclk','dds',
'deal','dealer','deals','degree','delivery','dell','deloitte','democrat','dental','design',
'dev','dhl','diamonds','diet','digital','direct','directory','discount','discover',
'dish','diy','dnp','docs','doctor','dog','domains','dot','download','drive','dtv','dubai',
'dunlop','dupont','durban','dvag','dvr','earth','eat','eco','edeka','edu','education',
'email','emerck','energy','engineering','enterprises','epson','equipment','ericsson',
'erni','estate','esurance','etisalat','eurovision','eus','events','exchange','expert',
'exposed','express','extraspace','fage','fail','fairwinds','faith','family','fan','fans',
'farm','farmers','fashion','fast','fedex','feedback','ferrari','ferrero','fiat','fidelity',
'fido','film','final','finance','financial','fire','firestone','firmdale','fish','fishing',
'fit','fitness','flights','florist','flowers','fly','foo','food','foodnetwork','football',
'ford','forex','forsale','forum','foundation','fox','free','fresenius','frl','frogans',
'frontdoor','frontier','ftr','fujitsu','fun','fund','furniture','futbol','fyi','gal',
'gallery','gallo','gallup','game','games','gap','garden','gay','gbiz','gdn','gea',
'gent','genting','george','ggee','gift','gifts','gives','giving','glass','gle','global',
'globo','gmail','gmbh','gold','goldpoint','golf','goo','goodyear','goog','google','gop',
'got','gov','grainger','graphics','gratis','green','gripe','grocery','group','guardian',
'gucci','guge','guide','guitars','guru','hair','hamburg','hangout','haus','hbo','hdfc',
'hdfcbank','health','healthcare','help','helsinki','here','hermes','hgtv','hiphop',
'hisamitsu','hitachi','hiv','hkt','hockey','holdings','holiday','homedepot','homegoods',
'homes','homesense','honda','horse','hospital','host','hosting','hot','hoteles','hotels',
'hotmail','house','how','hsbc','hughes','hyatt','hyundai','ibm','icbc','ice','icu','ieee',
'ifm','ikano','imamat','imdb','immo','immobilien','industries','infiniti','info','ing',
'ink','institute','insurance','insure','int','international','intuit','investments','ipiranga',
'irish','ismaili','ist','istanbul','itau','itv','jaguar','java','jcb','jeep','jetzt',
'jewelry','jio','jll','jobs','joburg','jot','joy','jpmorgan','jprs','juegos','juniper',
'kaufen','kddi','kerryhotels','kerrylogistics','kerryproperties','kfh','kia','kids','kim',
'kinder','kindle','kitchen','kiwi','koeln','komatsu','kosher','kpmg','kpn','krd','kred',
'kuokgroup','kyoto','lacaixa','lamborghini','lamer','lancaster','land','landrover',
'lanxess','lasalle','lat','latino','latrobe','law','lawyer','lds','lease','leclerc',
'lefrak','legal','lego','lexus','lgbt','lidl','life','lifeinsurance','lifestyle','lighting',
'like','lilly','limited','limo','lincoln','link','lipsy','live','living','llc','llp',
'loan','loans','locker','locus','lol','london','lotte','lotto','love','lpl','lplfinancial',
'ltd','ltda','lundbeck','luxe','luxury','madrid','maif','maison','makeup','man','management',
'mango','map','market','marketing','markets','marriott','marshalls','mba','mckinsey','med',
'media','meet','melbourne','meme','memorial','men','menu','merckmsd','miami','microsoft',
'mil','mini','mint','mit','mitsubishi','mobi','mobile','moda','moe','moi','mom','monash',
'money','monster','mormon','mortgage','moscow','moto','motorcycles','mov','movie','msd',
'mtn','mtr','music','mutual','nab','nagoya','name','natura','navy','nba','net','netbank',
'netflix','network','neustar','new','news','next','nextdirect','nexus','nfl','ngo','nhk',
'nico','nike','nikon','ninja','nissan','nissay','nokia','norton','now','nowruz','nra',
'nrw','ntt','nyc','obi','observer','office','okinawa','olayan','olayangroup','oldnavy',
'ollo','omega','one','ong','onl','online','ooo','open','oracle','orange','org','organic',
'origins','osaka','otsuka','ott','ovh','page','panasonic','paris','pars','partners','parts',
'party','passagens','pay','pccw','pet','pfizer','pharmacy','phd','philips','phone','photo',
'photography','photos','physio','pics','pictet','pictures','pid','pin','ping','pink',
'pioneer','pizza','place','play','playstation','plumbing','plus','pnc','pohl','poker',
'politie','porn','post','pramerica','praxi','press','prime','pro','prod','productions',
'prof','progressive','promo','properties','property','protection','pru','prudential',
'pub','pwc','qpon','quebec','quest','racing','radio','read','realestate','realtor',
'realty','recipes','red','redstone','redumbrella','rehab','reise','reisen','reit','reliance',
'ren','rent','rentals','repair','report','republican','rest','restaurant','review','reviews',
'rexroth','rich','richardli','ricoh','rightathome','rio','rip','rocher','rocks','rodeo',
'rogers','room','rsvp','rugby','ruhr','run','rwe','ryukyu','safe','safety','sakura','sale',
'salon','samsclub','samsung','sandvik','sandvikcoromant','sanofi','sap','sarl','sas',
'save','saxo','sbi','sbs','sca','scb','schaeffler','schmidt','scholarships','school',
'schule','schwarz','science','scjohnson','scot','search','seat','secure','security',
'seek','select','sener','services','ses','seven','sew','sex','sexy','sfr','shangrila',
'sharp','shaw','shell','shia','shiksha','shoes','shop','shopping','shouji','show','silk',
'sina','singles','ski','skin','sky','skype','smile','sncf','soccer','social','softbank',
'software','sohu','solar','solutions','song','sony','soy','spa','space','sport','spot',
'srl','stada','staples','star','statebank','statefarm','stc','stcgroup','stockholm',
'storage','store','stream','studio','study','style','sucks','supplies','supply','support',
'surf','surgery','suzuki','swatch','swiss','sydney','systems','tab','taipei','talk',
'taobao','target','tatamotors','tatar','tattoo','tax','taxi','tci','tdk','team','tech',
'technology','tel','temasek','tennis','teva','tiaa','tickets','tienda','tips','tires',
'tirol','tjmaxx','tjx','tkmaxx','today','tokyo','tools','top','toray','toshiba','total',
'tours','town','toyota','toys','trade','trading','training','travel','travelers',
'travelersinsurance','trust','trv','tube','tui','tunes','tushu','tvs','ubank','unicom',
'university','uno','uol','ups','vacations','vana','vanguard','vegas','ventures','verisign',
'versicherung','vet','viajes','video','vig','viking','villas','vin','vip','virgin','visa',
'vision','viva','vivo','vlaanderen','vodka','volkswagen','volvo','vote','voting','voto',
'voyage','vuelos','wales','walmart','walter','wang','wanggou','watch','watches','weather',
'weatherchannel','webcam','weber','website','wed','wedding','weibo','weir','whoswho',
'wien','wiki','williamhill','win','windows','wine','winners','wme','wolterskluwer',
'woodside','work','works','world','wow','wtc','wtf','xbox','xerox','xfinity','xihuan',
'xin','xxx','xyz','yachts','yahoo','yamaxun','yandex','yodobashi','yoga','yokohama',
'you','youtube','yun','zappos','zara','zero','zip','zone','zuerich'
]);
// Real two-label public suffixes (e.g. co.uk) — base domain check
const REAL_SLD_TLDS = new Set([
'co.uk','org.uk','me.uk','net.uk','ltd.uk','plc.uk','sch.uk','gov.uk','nhs.uk','police.uk',
'com.au','net.au','org.au','edu.au','gov.au','asn.au','id.au',
'co.nz','net.nz','org.nz','edu.nz','govt.nz','geek.nz','gen.nz','maori.nz',
'co.za','org.za','net.za','edu.za','gov.za','web.za',
'com.br','net.br','org.br','edu.br','gov.br','mil.br',
'com.ar','net.ar','org.ar','edu.ar','gov.ar',
'com.mx','net.mx','org.mx','edu.mx','gob.mx',
'com.cn','net.cn','org.cn','edu.cn','gov.cn','ac.cn',
'co.jp','ne.jp','or.jp','ac.jp','go.jp','ad.jp',
'co.in','net.in','org.in','edu.in','gov.in','ac.in','res.in',
'co.ke','or.ke','ac.ke','go.ke','ne.ke',
'com.sg','net.sg','org.sg','edu.sg','gov.sg',
'com.hk','net.hk','org.hk','edu.hk','gov.hk',
'com.tw','net.tw','org.tw','edu.tw','gov.tw',
'com.my','net.my','org.my','edu.my','gov.my',
'com.pk','net.pk','org.pk','edu.pk','gov.pk',
'com.ng','net.ng','org.ng','edu.ng','gov.ng',
'com.gh','net.gh','org.gh','edu.gh','gov.gh',
'com.eg','net.eg','org.eg','edu.eg','gov.eg',
'com.tr','net.tr','org.tr','edu.tr','gov.tr',
'com.sa','net.sa','org.sa','edu.sa','gov.sa',
'com.ae','net.ae','org.ae','edu.ae','gov.ae',
'com.il','net.il','org.il','edu.il','gov.il',
'co.il','ac.il',
'com.ua','net.ua','org.ua','edu.ua','gov.ua',
'com.pl','net.pl','org.pl','edu.pl','gov.pl',
'com.de','net.de','org.de',
'com.fr','net.fr','org.fr',
'com.es','net.es','org.es','edu.es','gob.es',
'com.it','net.it','org.it','edu.it','gov.it',
'com.ru','net.ru','org.ru','edu.ru','gov.ru',
'com.vn','net.vn','org.vn','edu.vn','gov.vn',
'com.ph','net.ph','org.ph','edu.ph','gov.ph',
'com.id','net.id','org.id','edu.id','go.id',
'com.pe','net.pe','org.pe','edu.pe','gob.pe',
'com.co','net.co','org.co','edu.co','gov.co',
'com.ve','net.ve','org.ve','edu.ve','gov.ve',
'com.ec','net.ec','org.ec','edu.ec','gov.ec',
'com.bo','net.bo','org.bo','edu.bo','gov.bo',
'com.py','net.py','org.py','edu.py','gov.py',
'com.uy','net.uy','org.uy','edu.uy','gub.uy',
'com.ni','net.ni','org.ni','edu.ni','gob.ni',
'com.cr','net.cr','org.cr','edu.cr','go.cr',
'com.gt','net.gt','org.gt','edu.gt','gob.gt',
'com.hn','net.hn','org.hn','edu.hn','gob.hn',
'com.sv','net.sv','org.sv','edu.sv','gob.sv',
'com.pa','net.pa','org.pa','edu.pa','gob.pa',
'com.do','net.do','org.do','edu.do','gob.do',
'com.cu','net.cu','org.cu','edu.cu','inf.cu',
'com.pr','net.pr','org.pr','edu.pr','gov.pr',
'com.tt','net.tt','org.tt','edu.tt','gov.tt',
'com.jm','net.jm','org.jm','edu.jm','gov.jm',
'com.bb','net.bb','org.bb','edu.bb','gov.bb',
'com.lc','net.lc','org.lc','edu.lc','gov.lc',
'com.vc','net.vc','org.vc','edu.vc','gov.vc',
'com.dm','net.dm','org.dm','edu.dm','gov.dm',
'com.ag','net.ag','org.ag','edu.ag','gov.ag',
'com.kn','net.kn','org.kn','edu.kn','gov.kn',
'com.gd','net.gd','org.gd','edu.gd','gov.gd',
'com.ms','net.ms','org.ms','edu.ms','gov.ms',
'com.ai','net.ai','org.ai','edu.ai','gov.ai',
'com.vg','net.vg','org.vg','edu.vg','gov.vg',
'com.ky','net.ky','org.ky','edu.ky','gov.ky',
'com.tc','net.tc','org.tc','edu.tc','gov.tc',
'com.bm','net.bm','org.bm','edu.bm','gov.bm',
'com.bs','net.bs','org.bs','edu.bs','gov.bs',
'com.aw','net.aw','org.aw',
'com.na','net.na','org.na','edu.na','gov.na',
'com.zm','net.zm','org.zm','edu.zm','gov.zm',
'com.zw','net.zw','org.zw','edu.zw','gov.zw',
'com.mz','net.mz','org.mz','edu.mz','gov.mz',
'com.tz','net.tz','org.tz','edu.tz','go.tz',
'com.ug','net.ug','org.ug','edu.ug','go.ug',
'com.rw','net.rw','org.rw','edu.rw','gov.rw',
'com.et','net.et','org.et','edu.et','gov.et',
'com.sd','net.sd','org.sd','edu.sd','gov.sd',
'com.ly','net.ly','org.ly','edu.ly','gov.ly',
'com.tn','net.tn','org.tn','edu.tn','gov.tn',
'com.dz','net.dz','org.dz','edu.dz','gov.dz',
'com.ma','net.ma','org.ma','edu.ma','gov.ma',
'com.sn','net.sn','org.sn','edu.sn','gov.sn',
'com.ci','net.ci','org.ci','edu.ci','gov.ci',
'com.cm','net.cm','org.cm','edu.cm','gov.cm',
'com.bf','net.bf','org.bf','edu.bf','gov.bf',
'com.ml','net.ml','org.ml','edu.ml','gov.ml',
'com.ne','net.ne','org.ne','edu.ne','gov.ne',
'com.td','net.td','org.td','edu.td','gov.td',
'com.mr','net.mr','org.mr','edu.mr','gov.mr',
'com.gn','net.gn','org.gn','edu.gn','gov.gn',
'com.sl','net.sl','org.sl','edu.sl','gov.sl',
'com.lr','net.lr','org.lr','edu.lr','gov.lr',
'com.gw','net.gw','org.gw','edu.gw','gov.gw',
'com.gm','net.gm','org.gm','edu.gm','gov.gm',
'com.cv','net.cv','org.cv','edu.cv','gov.cv',
'com.st','net.st','org.st','edu.st','gov.st',
'com.ga','net.ga','org.ga','edu.ga','gov.ga',
'com.cg','net.cg','org.cg','edu.cg','gov.cg',
'com.cd','net.cd','org.cd','edu.cd','gov.cd',
'com.ao','net.ao','org.ao','edu.ao','gov.ao',
'com.bw','net.bw','org.bw','edu.bw','gov.bw',
'com.ls','net.ls','org.ls','edu.ls','gov.ls',
'com.sz','net.sz','org.sz','edu.sz','gov.sz',
'com.mg','net.mg','org.mg','edu.mg','gov.mg',
'com.mu','net.mu','org.mu','edu.mu','gov.mu',
'com.re','net.re','org.re','edu.re','gov.re',
'com.yt','net.yt','org.yt',
'com.sc','net.sc','org.sc','edu.sc','gov.sc',
'com.km','net.km','org.km','edu.km','gov.km',
'com.dj','net.dj','org.dj','edu.dj','gov.dj',
'com.so','net.so','org.so','edu.so','gov.so',
'com.er','net.er','org.er','edu.er','gov.er',
'com.bi','net.bi','org.bi','edu.bi','gov.bi',
'com.mw','net.mw','org.mw','edu.mw','gov.mw',
'com.zm','net.zm','org.zm',
'com.bd','net.bd','org.bd','edu.bd','gov.bd',
'com.lk','net.lk','org.lk','edu.lk','gov.lk',
'com.np','net.np','org.np','edu.np','gov.np',
'com.mm','net.mm','org.mm','edu.mm','gov.mm',
'com.kh','net.kh','org.kh','edu.kh','gov.kh',
'com.la','net.la','org.la','edu.la','gov.la',
'com.vn','net.vn','org.vn','edu.vn','gov.vn',
'com.mn','net.mn','org.mn','edu.mn','gov.mn',
'com.kz','net.kz','org.kz','edu.kz','gov.kz',
'com.uz','net.uz','org.uz','edu.uz','gov.uz',
'com.tm','net.tm','org.tm','edu.tm','gov.tm',
'com.tj','net.tj','org.tj','edu.tj','gov.tj',
'com.kg','net.kg','org.kg','edu.kg','gov.kg',
'com.af','net.af','org.af','edu.af','gov.af',
'com.pk','net.pk','org.pk','edu.pk','gov.pk',
'com.ir','net.ir','org.ir','edu.ir','gov.ir',
'com.iq','net.iq','org.iq','edu.iq','gov.iq',
'com.sy','net.sy','org.sy','edu.sy','gov.sy',
'com.lb','net.lb','org.lb','edu.lb','gov.lb',
'com.jo','net.jo','org.jo','edu.jo','gov.jo',
'com.ps','net.ps','org.ps','edu.ps','gov.ps',
'com.ye','net.ye','org.ye','edu.ye','gov.ye',
'com.om','net.om','org.om','edu.om','gov.om',
'com.kw','net.kw','org.kw','edu.kw','gov.kw',
'com.bh','net.bh','org.bh','edu.bh','gov.bh',
'com.qa','net.qa','org.qa','edu.qa','gov.qa',
'com.ge','net.ge','org.ge','edu.ge','gov.ge',
'com.am','net.am','org.am','edu.am','gov.am',
'com.az','net.az','org.az','edu.az','gov.az',
'com.by','net.by','org.by','edu.by','gov.by',
'com.ua','net.ua','org.ua','edu.ua','gov.ua',
'com.md','net.md','org.md','edu.md','gov.md',
'com.ro','net.ro','org.ro','edu.ro','gov.ro',
'com.bg','net.bg','org.bg','edu.bg','gov.bg',
'com.mk','net.mk','org.mk','edu.mk','gov.mk',
'com.al','net.al','org.al','edu.al','gov.al',
'com.rs','net.rs','org.rs','edu.rs','gov.rs',
'com.hr','net.hr','org.hr','edu.hr','gov.hr',
'com.ba','net.ba','org.ba','edu.ba','gov.ba',
'com.me','net.me','org.me','edu.me','gov.me',
'com.si','net.si','org.si','edu.si','gov.si',
'com.sk','net.sk','org.sk','edu.sk','gov.sk',
'com.cz','net.cz','org.cz','edu.cz','gov.cz',
'com.pl','net.pl','org.pl','edu.pl','gov.pl',
'com.hu','net.hu','org.hu','edu.hu','gov.hu',
'com.at','net.at','org.at','edu.at','gov.at',
'com.ch','net.ch','org.ch','edu.ch','gov.ch',
'com.li','net.li','org.li','edu.li','gov.li',
'com.be','net.be','org.be','edu.be','gov.be',
'com.nl','net.nl','org.nl','edu.nl','gov.nl',
'com.lu','net.lu','org.lu','edu.lu','gov.lu',
'com.dk','net.dk','org.dk','edu.dk','gov.dk',
'com.se','net.se','org.se','edu.se','gov.se',
'com.no','net.no','org.no','edu.no','gov.no',
'com.fi','net.fi','org.fi','edu.fi','gov.fi',
'com.is','net.is','org.is','edu.is','gov.is',
'com.ie','net.ie','org.ie','edu.ie','gov.ie',
'com.pt','net.pt','org.pt','edu.pt','gov.pt',
'com.gr','net.gr','org.gr','edu.gr','gov.gr',
'com.cy','net.cy','org.cy','edu.cy','gov.cy',
'com.mt','net.mt','org.mt','edu.mt','gov.mt',
'com.ee','net.ee','org.ee','edu.ee','gov.ee',
'com.lv','net.lv','org.lv','edu.lv','gov.lv',
'com.lt','net.lt','org.lt','edu.lt','gov.lt'
]);
/**
* Validate a virtual host hostname.
* Rules:
* 1. Must have at least 2 dots (3 labels minimum: host.second.tld)
* 2. Each label: only [a-z0-9-], no leading/trailing hyphen, non-empty
* 3. The last two labels (base domain, e.g. "hole.sail") must not be a
* real public TLD or second-level public suffix
* Returns { ok: true } or { ok: false, error: string }
*/
function isValidVhostHostname(hostname) {
if (!hostname) return { ok: false, error: 'Hostname is required' };
const labels = hostname.split('.');
if (labels.length < 3) {
return { ok: false, error: 'Hostname must have the form host.second.tld (e.g. myapp.hole.sail) — single-dot names are not allowed' };
}
for (const label of labels) {
if (!label) return { ok: false, error: 'Hostname contains empty labels' };
if (!/^[a-z0-9-]+$/.test(label)) return { ok: false, error: 'Hostname contains invalid characters — only letters, digits and hyphens allowed' };
if (label.startsWith('-') || label.endsWith('-')) return { ok: false, error: 'Hostname labels must not start or end with a hyphen' };
}
const tld = labels[labels.length - 1];
const baseDomain = labels.slice(-2).join('.');
if (REAL_TLDS.has(tld) || REAL_SLD_TLDS.has(baseDomain)) {
return { ok: false, error: 'The TLD ".' + baseDomain + '" is a real registered domain — use a private TLD like .hole.sail or .my.internal' };
}
return { ok: true };
}
/** Extract the two-label base domain from a hostname (e.g. "hole.sail" from "myapp.hole.sail") */
function extractBaseDomain(hostname) {
const parts = hostname.split('.');
return parts.slice(-2).join('.');
}
/** Extract unique two-label base domains from a list of virtual host objects */
function extractActiveTlds(virtualHosts) {
const seen = new Set();
seen.add('hole.sail'); // always include baseline
for (const v of (virtualHosts || [])) {
if (v.hostname) seen.add(extractBaseDomain(v.hostname));
}
return Array.from(seen).map(b => '.' + b);
}
function stateTag(state) { function stateTag(state) {
const dot = (color) => `<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--${color});margin-right:5px;flex-shrink:0;${color === 'green' ? 'box-shadow:0 0 5px var(--green);' : ''}"></span>`; const dot = (color) => `<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--${color});margin-right:5px;flex-shrink:0;${color === 'green' ? 'box-shadow:0 0 5px var(--green);' : ''}"></span>`;
if (!state || state === '—') return `<span class="badge badge-neutral">${dot('text4')}—</span>`; if (!state || state === '—') return `<span class="badge badge-neutral">${dot('text4')}—</span>`;
@@ -1653,8 +2002,8 @@ function setupEvents() {
let hostname = (hostnameEl?.value || '').trim(); let hostname = (hostnameEl?.value || '').trim();
hostname = hostname.replace(/^https?:\/\//i, '').replace(/[/:?#].*$/, '').toLowerCase().trim(); hostname = hostname.replace(/^https?:\/\//i, '').replace(/[/:?#].*$/, '').toLowerCase().trim();
const hsUrl = (hsUrlEl?.value || '').trim(); const hsUrl = (hsUrlEl?.value || '').trim();
if (!hostname) { showModalError('modal-addVhost', 'addVhostError', 'Hostname is required'); return; } const hostnameValidation = isValidVhostHostname(hostname);
if (!hostname.endsWith('.hole.sail')) { showModalError('modal-addVhost', 'addVhostError', 'Hostname must end with .hole.sail (e.g. myapp.hole.sail)'); return; } if (!hostnameValidation.ok) { showModalError('modal-addVhost', 'addVhostError', hostnameValidation.error); return; }
if (!hsUrl || !hsUrl.startsWith('hs://')) { showModalError('modal-addVhost', 'addVhostError', 'Enter a valid hs:// URL'); return; } if (!hsUrl || !hsUrl.startsWith('hs://')) { showModalError('modal-addVhost', 'addVhostError', 'Enter a valid hs:// URL'); return; }
const btn = $('addVhostSubmit'); const btn = $('addVhostSubmit');
if (btn) { btn.disabled = true; btn.textContent = 'Adding…'; } if (btn) { btn.disabled = true; btn.textContent = 'Adding…'; }
+5 -1
View File
@@ -9,7 +9,8 @@
"notifications", "notifications",
"tabs", "tabs",
"declarativeNetRequest", "declarativeNetRequest",
"proxy" "proxy",
"permissions"
], ],
"action": { "action": {
"default_title": "Holesail Dashboard", "default_title": "Holesail Dashboard",
@@ -64,5 +65,8 @@
"host_permissions": [ "host_permissions": [
"*://*.host.local/*", "*://*.host.local/*",
"*://*.hole.sail/*" "*://*.hole.sail/*"
],
"optional_host_permissions": [
"*://*/*"
] ]
} }
+47 -17
View File
@@ -5,6 +5,7 @@
const path = require('bare-path'); const path = require('bare-path');
const fs = require('bare-fs'); const fs = require('bare-fs');
const tls = require('tls');
let https = null; let https = null;
let http = null; let http = null;
@@ -45,26 +46,55 @@ function start(port, certsDirOrCA, callback) {
return proxyServer; return proxyServer;
} }
// Wildcard cert for *.hole.sail — Chrome accepts wildcards when base domain has 2+ labels if (typeof certsDirOrCA !== 'object' || !certsDirOrCA.getOrCreateDomainCert) {
const certDomain = '*.hole.sail'; if (callback) callback(new Error('certsDirOrCA must provide getOrCreateDomainCert'));
let cert = null;
let key = null;
if (typeof certsDirOrCA === 'object' && certsDirOrCA.getOrCreateDomainCert) {
const result = certsDirOrCA.getOrCreateDomainCert(certDomain, [
{ type: 2, value: '*.hole.sail' },
{ type: 2, value: 'hole.sail' }
]);
if (result) {
cert = typeof result.cert === 'string' ? Buffer.from(result.cert) : result.cert;
key = typeof result.key === 'string' ? Buffer.from(result.key) : result.key;
}
}
if (!cert || !key) {
if (callback) callback(new Error('Could not get wildcard cert for ' + certDomain));
return null; return null;
} }
const opts = { cert, key }; // Pre-generate the baseline *.hole.sail cert so the server can start immediately.
// Additional TLD certs are generated lazily via SNICallback on first connection.
const baseResult = certsDirOrCA.getOrCreateDomainCert('*.hole.sail', [
{ type: 2, value: '*.hole.sail' },
{ type: 2, value: 'hole.sail' }
]);
if (!baseResult) {
if (callback) callback(new Error('Could not get baseline wildcard cert for *.hole.sail'));
return null;
}
// SNI callback — lazily issues a wildcard cert per two-label base domain
function sniCallback(servername, cb) {
const parts = (servername || '').split('.');
// Need at least 3 labels (host.second.tld) to derive a 2-label base domain
const baseDomain = parts.length >= 3 ? parts.slice(-2).join('.') : 'hole.sail';
const wildcard = '*.' + baseDomain;
debugLog('sniCallback: servername=', servername, 'baseDomain=', baseDomain, 'wildcard=', wildcard);
const result = certsDirOrCA.getOrCreateDomainCert(wildcard, [
{ type: 2, value: wildcard },
{ type: 2, value: baseDomain }
]);
if (!result) {
if (process.stderr) process.stderr.write('[https-proxy] SNI: no cert for ' + wildcard + '\n');
return cb(new Error('No cert for ' + wildcard));
}
try {
const ctx = tls.createSecureContext({
cert: typeof result.cert === 'string' ? result.cert : result.cert.toString(),
key: typeof result.key === 'string' ? result.key : result.key.toString()
});
cb(null, ctx);
} catch (e) {
if (process.stderr) process.stderr.write('[https-proxy] SNI createSecureContext error: ' + e.message + '\n');
cb(e);
}
}
const opts = {
cert: typeof baseResult.cert === 'string' ? Buffer.from(baseResult.cert) : baseResult.cert,
key: typeof baseResult.key === 'string' ? Buffer.from(baseResult.key) : baseResult.key,
SNICallback: sniCallback
};
let callbackCalled = false; let callbackCalled = false;
function done(err) { function done(err) {
if (callbackCalled) return; if (callbackCalled) return;