本文章的部分代码由 Claude 3.5 Sonnet 生成,此外我的水平有限。故无法保证代码和实现方案的准确性、完整性和功能性,仅用作技术笔记。

如果你知道标题在说什么,请跳到 解决方案 部分。

问题在于

当我在家里的 NAS 上跑了一个 Web 服务,家宽的 Web 服务端口被封禁了,可我不想访问的时候带上端口(很丑)怎么办?

反向代理

通常情况下我们会找一个中间服务器来做反代,内网穿透或者自己找台VPS都可以实现。这么做的问题是什么?

  • NAS 和反代服务器哪个带宽低哪个就是瓶颈。
  • 现在数据要多走点距离了,访问会变慢。

URL转发

许多 DNS 服务提供商都允许你设置一个特殊的记录,只要把正经的域名转发到 NAS 上那个带端口的 URL 即可。这么做的问题是什么?

  • 如果你设置显性转发,那么用户还是会在地址栏看到那奇丑无比的 URL。
  • 如果你设置隐性转发,大部分 DNS 服务提供商就不会允许配置 https。这就意味着用户虽然能看到好看的 URL,却会在 URL 旁边看到一个奇丑无比的 不安全 标识。

解决方案

直接说结论,我们找一台有公网IP的机子(下称代理机),域名解析过去,然后在这台机子上摆一页 Html,用 iframe 把源站(没有公网IP的机子)页面嵌进去,然后用 js 对站点标题和图标进行同步。

优势

  1. 流量不经过代理机,直接到源站。
  2. 代理机负载极低,只是提供小型静态 Html 文件,所以配置可以很低,你甚至可以用 Github Pages
  3. 用户不会看到实际的 URL,同时保持正确的图标和标题(需修改源站代码)。

劣势

  1. 源站必须允许跨域。
  2. iframe 可能导致加载速度略慢。
  3. 可能出现查询字符串以及一些莫名其妙的问题。
  4. 极其不利于 SEO。

总的来说,这方法适合个人博客或 NAS 上的 Jellyfin、MT Photos 之类的。对于大型网站、动态内容多的社交平台或 API 密集型应用,不建议使用,图个好看而带来一堆问题,得不偿失啦。

步骤

根据需求修改下方的代码。

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
<!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

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
(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> 标签前添加一行代码:

1
<script src="/dynamic-iframe-agent.js"></script>

首先为了确保你的站点能够访问这个文件,在该站点配置中添加以下代码。

1
2
3
4
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 字样。

随后在你的站点配置里添加以下代码。

1
2
3
4
proxy_set_header Accept-Encoding "";

sub_filter '</body>' '<script src="/dynamic-iframe-agent.js"></script></body>';
sub_filter_once on;

设置 Accept-Encoding 是因为 sub_module 是通过替换文件里的指定字样以达到修改目的的,如果源站开启了压缩则无法生效。

这里提供一份完整的站点配置示例。

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
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 不加载。