免责: 本文章的部分代码由 Claude 3.5 Sonnet 生成,此外我的水平有限。故无法保证代码和实现方案的准确性、完整性和功能性,仅用作技术笔记。
提示: 如果你知道标题在说什么,请跳到 解决方案 部分。
问题在于
当我在家里的 NAS 上跑了一个 Web 服务,家宽的 Web 服务端口被封禁了,可我不想访问的时候带上端口(很丑)怎么办?
反向代理
通常情况下我们会找一个中间服务器来做反代,内网穿透或者自己找台 VPS 都可以实现。这么做的问题是什么?
- NAS 和反代服务器哪个带宽低哪个就是瓶颈。
- 现在数据要多走点距离了,访问会变慢。
URL 转发
许多 DNS 服务提供商都允许你设置一个特殊的记录,只要把正经的域名转发到 NAS 上那个带端口的 URL 即可。这么做的问题是什么?
- 如果你设置显性转发,那么用户还是会在地址栏看到那奇丑无比的 URL。
- 如果你设置隐性转发,大部分 DNS 服务提供商就不会允许配置 https。这就意味着用户虽然能看到好看的 URL,却会在 URL 旁边看到一个奇丑无比的
不安全标识。
解决方案
直接说结论,我们找一台有公网 IP 的机子(下称代理机),域名解析过去,然后在这台机子上摆一页 Html,用 iframe 把源站(没有公网 IP 的机子)页面嵌进去,然后用 js 对站点标题和图标进行同步。
优势
- 流量不经过代理机,直接到源站。
- 代理机负载极低,只是提供小型静态 Html 文件,所以配置可以很低,你甚至可以用 GitHub Pages。
- 用户不会看到实际的 URL,同时保持正确的图标和标题(需修改源站代码)。
劣势
- 源站必须允许跨域。
iframe可能导致加载速度略慢。- 可能出现查询字符串以及一些莫名其妙的问题。
- 极其不利于 SEO。
总的来说,这方法适合个人博客或 NAS 上的 Jellyfin、MT Photos 之类的。对于大型网站、动态内容多的社交平台或 API 密集型应用,不建议使用,图个好看而带来一堆问题,得不偿失啦。
步骤
根据需求修改下方的代码。
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Loading...</title>
<!-- 如果你想,可以设置一个默认图标,在源站没有图标的时候使用 -->
<link id="favicon" rel="icon" href="favicon.png" type="image/png">
<style>
body, html {
margin: 0;
padding: 0;
overflow: hidden;
width: 100%;
height: 100%;
}
iframe {
border: none;
width: 100%;
height: 100%;
}
</style>
</head>
<body>
<iframe id="contentFrame" src=""></iframe>
<script>
const hostMap = {
// '你希望用户访问的URL':'你的服务实际所在的URL',
'example.com': 'https://source.top:1234',
'xxx.example.com': 'https://source.top:5678',
};
const currentHost = window.location.host;
const frameSource = hostMap[currentHost];
if (frameSource) {
const iframe = document.getElementById('contentFrame');
const urlParams = window.location.search + window.location.hash;
iframe.src = frameSource + urlParams;
let currentFavicon = 'favicon.png';
let currentTitle = 'Loading...';
function updateFaviconAndTitle(favicon, title) {
if (favicon && favicon !== currentFavicon) {
document.getElementById('favicon').href = favicon;
currentFavicon = favicon;
}
if (title && title !== currentTitle) {
document.title = title;
currentTitle = title;
}
}
window.addEventListener('message', function(event) {
if (event.origin === frameSource) {
const { favicon, title } = event.data;
console.log('[Dynamic Iframe Meta Sync] Got favicon:', favicon);
console.log('[Dynamic Iframe Meta Sync] Got title:', title);
updateFaviconAndTitle(favicon, title);
}
}, false);
const updateUrl = (event) => {
const newUrl = new URL(event.target.contentWindow.location.href);
const newHost = newUrl.host;
if (newHost !== currentHost) {
window.history.pushState({}, '', newUrl.origin + newUrl.pathname + urlParams);
}
};
iframe.onload = () => {
const iframeWindow = iframe.contentWindow;
iframeWindow.addEventListener('popstate', updateUrl);
iframeWindow.addEventListener('hashchange', updateUrl);
};
} else {
document.body.innerHTML = '<h1>无效的访问地址</h1>';
}
</script>
</body>
</html>
hostMap是一个映射表,用于存储表面 URL 和实际 URL 的对应关系- 左侧是用户访问的域名,右侧是实际服务所在的 URL
- 可根据需要添加多个映射关系
- 确保所有需要代理的域名都解析到此 Html 文件
修改后保存为 index.html,然后丢到有公网 IP 的服务器上,或者上传到 GitHub Pages 之类的地方托管也可以,确保可以通过你设置的 URL 访问到这个文件。
接下来由于跨域问题,代理站无法获取源站的图标和标题,因此我们为源站添加一个 js,来告知代理站当前的标题和图标。
将下面的文件保存为 dynamic-iframe-agent.js。
(function() {
let originalTitle = document.title;
let originalSetTitle;
let intervalId;
function sendMetaInfo() {
try {
const favicon = document.querySelector('link[rel="icon"]') || document.querySelector('link[rel="shortcut icon"]');
const faviconUrl = favicon ? favicon.href : '';
let title = document.title;
// 如果标题为空,尝试从浏览器标签获取可见标题
if (!title.trim()) {
title = document.querySelector('title')?.textContent || '';
}
// 如果标题仍为空,启动定期检查
if (!title.trim() && !intervalId) {
intervalId = setInterval(checkVisibleTitle, 1000);
} else if (title.trim() && intervalId) {
clearInterval(intervalId);
intervalId = null;
}
if (window.parent !== window) {
console.log('[Dynamic Iframe Meta Sync] Sent favicon:', faviconUrl);
console.log('[Dynamic Iframe Meta Sync] Sent title:', title);
window.parent.postMessage({ favicon: faviconUrl, title: title }, '*');
}
} catch (error) {
console.error('Error in sendMetaInfo:', error);
}
}
function checkVisibleTitle() {
const visibleTitle = document.title || document.querySelector('title')?.textContent || '';
if (visibleTitle.trim()) {
sendMetaInfo();
clearInterval(intervalId);
intervalId = null;
}
}
function init() {
const titleDescriptor = Object.getOwnPropertyDescriptor(Document.prototype, 'title');
originalSetTitle = titleDescriptor.set;
Object.defineProperty(document, 'title', {
set: function(newTitle) {
if (originalSetTitle) {
originalSetTitle.call(this, newTitle);
}
originalTitle = newTitle;
sendMetaInfo();
},
get: function() {
return originalTitle;
}
});
sendMetaInfo();
// 监视 favicon 变化
const faviconObserver = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'attributes' && mutation.attributeName === 'href') {
sendMetaInfo();
}
});
});
const favicon = document.querySelector('link[rel="icon"]') || document.querySelector('link[rel="shortcut icon"]');
if (favicon) {
faviconObserver.observe(favicon, { attributes: true });
}
// 监视 title 变化
const titleObserver = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'childList' || mutation.type === 'characterData') {
sendMetaInfo();
}
});
});
const titleElement = document.querySelector('title');
if (titleElement) {
titleObserver.observe(titleElement, { childList: true, characterData: true, subtree: true });
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// 监视 head 变化,以防 favicon 或 title 后来被添加
const headObserver = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(function(node) {
if (node.nodeName === 'LINK' && (node.rel === 'icon' || node.rel === 'shortcut icon')) {
sendMetaInfo();
faviconObserver.observe(node, { attributes: true });
} else if (node.nodeName === 'TITLE') {
sendMetaInfo();
titleObserver.observe(node, { childList: true, characterData: true, subtree: true });
}
});
}
});
});
headObserver.observe(document.head, { childList: true, subtree: true });
})();
如果源站的页面不多,你可以手动在每个页面引用 dynamic-iframe-agent.js;而如果是动态站点或比较复杂的应用,我推荐在反代程序上配置引用 dynamic-iframe-agent.js,这里以 Nginx 为例,其它反代程序请自行查找修改响应内容的方法。
手动引用
在原站根目录下添加 dynamic-iframe-agent.js 文件,确保文件名正确且大小写无误。
在源站的每个 Html 文件里的 </body> 标签前添加一行代码:
<script src="/dynamic-iframe-agent.js"></script>
Nginx
首先为了确保你的站点能够访问这个文件,在该站点配置中添加以下代码。
location /dynamic-iframe-agent.js {
alias path/to/dynamic-iframe-agent.js;
add_header Cache-Control 'no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0';
}
记得将 path/to/dynamic-iframe-agent.js 修改为你的 dynamic-iframe-agent.js 文件的实际路径。
第二行设置 Cache-Control 是调试用的,施工完毕后确保无误可以删除这一行。
确保你的 Nginx 安装了 sub_filter 模块(通常是自带的),如果你不确定,使用 nginx -v 指令确定是否包含 --with-http_sub_module 字样。
随后在你的站点配置里添加以下代码。
proxy_set_header Accept-Encoding "";
sub_filter '</body>' '<script src="/dynamic-iframe-agent.js"></script></body>';
sub_filter_once on;
设置 Accept-Encoding 是因为 sub_module 是通过替换文件里的指定字样以达到修改目的的,如果源站开启了压缩则无法生效。
这里提供一份完整的站点配置示例。
server {
listen 2488 ssl;
server_name server.leever.cn;
proxy_set_header Host 127.0.0.1;
add_header 'Access-Control-Allow-Origin' 'https://rain.leever.cn';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range';
add_header Content-Security-Policy "frame-ancestors 'self' https://rain.leever.cn";
location /dynamic-iframe-agent.js {
alias D:/Software/Nginx/sub_filter/dynamic-iframe-agent.js;
}
location / {
proxy_pass http://127.0.0.1:8778;
proxy_set_header Accept-Encoding "";
sub_filter '</body>' '<script src="/dynamic-iframe-agent.js"></script></body>';
sub_filter_once on;
}
}
最后,你需要确认源站是支持跨域的,否则页面无法正常显示,你会在控制台看到 Uncaught DOMException: Blocked a frame with origin "https://example.com" from accessing a cross-origin frame. 之类的字样。
- 如果你使用了反代程序,可以查找对应的添加跨域支持的教程。
- 如果源站本身就不支持跨域,那我们可能就得动动源码了,这个过程很复杂且因人而异,这里不赘述。
注意: 代理站和源站需要同时使用 Https,或者同时使用 Http,两者协议不同会导致
iframe不加载。