客制化Captive Portal可以做到Logo、版型、編碼等自由搭配。對於中文用戶,這樣可以使中文正常顯示。
但是,一旦客製化,就會面臨有些關鍵html tag不知如何搭配才好。
然後Internal Captive Portal的Type又分為「帳密」、「email」、「不用帳密與email」,然後Custom Captive Portal又需要與這三種Type搭配,就需要Know-how了。
帳密對應Captive Portal Profile的User Login。
Email對應Captive Portal Profile的Guest Login。
無帳密與Email對應Captive Portal Profile的User Login&Guest Login兩者取消勾選。
以下內容將讓讀者可以在最快時間內,明白如何客製化出自己的Internal Captive Portal。
流程
- 決定Captive Portal的Type:帳密、Email、無帳密與Email
- 依據Type,從Controller上取得Template。
- 依據Template,調整成自己想要的版面。
- 上傳HTML,測試功能與微調。
概念
帳密、Email、無帳密與Email,在Html中,分別需要具備:
帳密:input「user」、input「password」。
Email:input「email」。
無帳密與Email:input「user」、input「password」。
※「」為name屬性,不是id屬性。
因此,帳密與無帳密與Email在驗證概念是相同的,只是前者的帳密需要使用者輸入,而後者的帳密則由html直接帶入,對使用者來說就無需輸入帳密。※無帳密與Email的使用的預設帳密需要先新增到Auth Server中。
input則是包含在form之下,當登入時,網頁會將所有input的資訊傳送給form的action,因此,action即為Controller的位置。而Internal Captive Portal的URL就是Controller的位置,所以一般來說Action只需要填入相對路徑(省略IP)即可。
action可以填入以下兩個其中一個,視情況調整:
「/cgi-bin/login」
「/auth/index.html/u」
一個最簡單的Captive Portal,如果是帳密,只需要做一個Form,設定action,然後新增input包含user、password,並且新增一個按鈕,讓Form能submit。
如果是email,只需要做一個Form,設定action,然後新增input包含email,並且新增一個按鈕,讓Form能submit。
其餘的功能可以視自己的需求或是別人的審美觀添加,只要確保核心功能能正常,自製Captive Portal將不會花太多時間除錯!
依據Level,從Controller上取得Template
ArubaOS中,針對三種Level的Captive Portal在內建都有提供Template,並且功能正常。
筆者在使用8.10.0.16以上時,發現內建的Captive Portal有功能異常,Preview時頁面空白且無html內容,因此改用8.6去提取出正常功能的Template。
要最快速提取Template的方法,就是新增一個Primary usage為Guest的WLAN。並且在Security選擇


透過Preview,可以看到內建的Internal Captive Portal Template,然後將網頁保存,基本上只需要html內容即可以。
內建的Internal Captive Portal Template其實具備不同Type的驗證能力,要用哪種?
在對應的style=”display: none”拿掉,input顯示在網頁上,就能使用。
如果type為帳密:將registered的style中的display: none拿掉。
如果type為帳密:將guest的style中的display: none拿掉。
以下為ArubaOS Internal Captive Portal Template,使用者需要依據需求調整。
<html xmlns="http://www.w3.org/1999/xhtml"><head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<title>Portal Login</title>
<link href="/auth/default1/styles.css" rel="stylesheet" media="screen" type="text/css">
<script language="javascript" type="text/javascript">
function cp_ua() {
this.div_ids = ['user-agreement', 'logins'];
this.cp_a_type = null;
this.init = function (cp_a_type) {
if(this.cp_a_type == null)
this.cp_a_type = cp_a_type;
};
this.show_forms = function(isUa, isDisabled) {
// noop
};
// When there is no credential or email input fields
this.only_aup = function(requested_url) {
var xform = document.getElementById("only_aup_form");
var url = document.getElementsByName("requested_url")[0];
url.value = requested_url;
xform.submit();
};
}
var CpUa = new cp_ua();
function validateLogin() {
var aupBox = document.getElementById("user-agreement");
var aupCheck = document.getElementById("agreement_agree");
/* Allow login only if the user checked the AUP box,
or if the AUP box is hidden (thus nothing to accept)
*/
if (aupCheck.checked || aupBox.style.display === "none") {
document.getElementById('regform').submit();
}
}
function showPolicy(doShow) {
var elem = document.getElementById('policyOverlay');
elem.style.display = doShow ? '' : 'none';
elem = document.getElementById('mask');
elem.style.display = doShow ? '' : 'none';
}
function focus() {
var focusElem;
var boxElem = document.getElementById("registered");
if (boxElem.style.display !== "none") {
focusElem = document.getElementById("user");
} else {
boxElem = document.getElementById("guest");
if (boxElem.style.display !== "none") {
focusElem = document.getElementById("email");
} else {
boxElem = document.getElementById("user-agreement");
if (boxElem.style.display !== "none") {
focusElem = document.getElementById("agreement_agree");
}
}
}
if (focusElem) {
focusElem.focus();
}
}
window.onload = function () {
try {
focus();
} catch (e) {
window.console.error(e);
}
};
</script>
</head>
<body style=" background-image:url();">
<div class="wrapper">
<div class="content">
<img class="banner-image" style="" src="/auth/default1/hpe_aruba_w.png">
<div id="instructions" style="display:none">
<div id="instructionstext" style=""></div>
</div>
<form action="/cgi-bin/login" id="regform" method="post" autocomplete="off">
<div id="registered" style="display: none">
<div class="login-field">
<input type="text" id="user" name="user" placeholder="Username" size="25" onkeypress="if(event && event.keyCode == 13){validateLogin();return false;}">
</div>
<div class="login-field">
<input type="password" id="password" name="password" placeholder="Password" size="25" onkeypress="if(event && event.keyCode == 13){validateLogin();return false;}">
</div>
</div>
<div id="guest" style="display: none">
<div id="emailbox" class="login-field">
<label for="email" accesskey="e">Email: </label>
<input type="text" id="email" name="email" placeholder="example@host.com" size="25" onkeypress="if(event && event.keyCode == 13){validateLogin();return false;}">
</div>
</div>
<input type="hidden" name="cmd" value="authenticate">
<div id="user-agreement" style="display">
<input type="checkbox" id="agreement_agree" name="agreementAck" value="Accept" onclick="">
<label for="agreement_agree" class="agreement-policy-label" style="">
I accept the <a class="agreement-policy-link" style="" href="javascript:void(0)" onclick="showPolicy(true); return false;">terms and conditions</a>
</label>
</div>
<div id="loginBox" style="display: none">
<a class="button-box login" href="javascript:void(0)" onclick="validateLogin();return false;">
<div name="login" class="button" style="">Sign in</div>
</a>
</div>
</form>
<div style="display: none">
<form action="login" id="only_aup_form" method="post" title="">
<input type="hidden" name="accept_aup" value="accept_aup">
<input type="hidden" name="requested_url" value="">
</form>
</div>
<div id="errorbox" style="display: none">
<img src="/images/alert-login-page.png">
<span id="failreason">
</span>
</div>
</div>
</div>
<div id="policyOverlay" style="display:none;">
<div class="wrapper">
<div class="policy-content">
<div class="policy-title">
Terms and Conditions
</div>
<div class="policy-text">
</div>
<div class="button-row">
<a class="button-box" href="javascript:void(0)" onclick="showPolicy(false);return false;">
<div name="okPolicy" class="button" style="">Close</div>
</a>
</div>
</div>
</div>
</div>
<div id="mask" style="display:none;"></div>
</body></html>
依據Template,調整成自己想要的版面
在過往,調整html成自己喜歡的樣子,需要學習html相關知識,練習,然後刻出。
現在,可以使用ChatGPT等大語言模型幫助。
為了讓大語言模型能順利調整成自己想要的樣子,我建議遵照以下的步驟
- 收集調整方向的Sample,以圖片形式或是Html形式,讓大語言模型能知道怎麼改。
- 版面、功能分離,即只需要讓大語言模型異動版面,勿讓大語言模型調整功能部分。
- 自備Logo與說明文字,並且指示大語言模型擺入指定位置。
- 若發現大語言模型調整的html不是最新的版本,則建立新的聊天。
使用範例
上傳Sample的圖片
<Aruba Internal Captive Portal Template 內容>
###
根據圖片,將以上HTML版面調整成相似風格,並且服務條款調整成指定內容。
避免異動任何tag與功能,只針對Style與文字進行調整。
調整後的HTML,不使用Canva,直接完整輸出。
###服務條款
xxxxxxxx
上傳HTML,測試功能與微調。
如果有使用背景圖片,需要在image中的src導入相對位置,相對位置又與Captive Portal Profile相關,所有相關上傳都會放在「upload/custom/<Captive Portal Profile Name>/」。
如果Captive Portal Profile叫「test_cppm_prof」,要導入logo.png上傳之後,html內容中關於logo的image src則設為「upload/custom/test_cppm_prof/logo.png」。
對於新手來說,這不友善。
因此!建議!直接讓大語言模型將Logo等圖片轉成base64並將輸出轉換成文字檔,然後使用image src直接導入base64。
<img src="data:image/png;base64,<image base64>" />
if image base64 = aaabbb
=> <img src="data:image/png;base64,aaabbb" />
這會讓html變得長且大,但可避免不懂如何使用多重上傳的技巧。
將html準備完成後,就可以準備上傳了,而在WLAN建立完成後,能調整Captive Portal的地方,在「Configuration > Roles & Policies > (Your Role) > Advanced View > Captive Portal」,或是「Configuration > WLANs > (Your WLAN) > Security」
「Configuration > Roles & Policies > (Your Role) > Advanced View > Captive Portal」

「Configuration > WLANs > (Your WLAN) > Security」

切換成Custom HTML,點擊File for Login Page旁的Browse上傳HTML與相關檔案。
我的HTML,搭配type「email」。
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>無線訪客網路登入</title>
<style>
/* 全域重置與排版 */
* { margin:0; padding:0; box-sizing:border-box; }
body {
display:flex;
align-items:center;
justify-content:center;
height:100vh;
background:#f0f2f5;
font-family:"Microsoft JhengHei",sans-serif;
color:#333;
}
.card {
background:#fff;
border-radius:12px;
box-shadow:0 4px 12px rgba(0,0,0,0.1);
padding:2rem;
max-width:360px;
width:90%;
text-align:center;
}
.card h1 {
font-size:1.5rem;
margin-bottom:1rem;
}
/* 表格對齊 */
.info-table {
width:100%;
border-collapse:collapse;
margin-bottom:1.5rem;
}
.info-table td {
padding:0.5rem 0.25rem;
font-size:0.95rem;
vertical-align:middle;
}
.info-table .label {
width:4em;
text-align:left;
white-space:nowrap; /* 避免換行 */
}
.info-table .value {
text-align:left;
word-break:break-all;
}
/* 按鈕及脈衝動畫 */
@keyframes pulse {
0%,100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.btn-connect {
display:block;
width:100%;
padding:0.75rem 0;
font-size:1rem;
border:none;
border-radius:8px;
background:#2a4099;
color:#fff;
cursor:pointer;
transition:background 0.3s;
animation:pulse 2s ease-in-out infinite;
}
.btn-connect:hover {
background:#1f3077;
animation-play-state:paused;
}
</style>
</head>
<body>
<div class="card">
<h1>無線訪客網路</h1>
<table class="info-table">
<tr>
<td class="label">SSID:</td>
<td class="value" id="ssidDisplay">載入中...</td>
</tr>
<tr>
<td class="label">IP 位址:</td>
<td class="value" id="ipDisplay">載入中...</td>
</tr>
<tr>
<td class="label">MAC 位址:</td>
<td class="value" id="macDisplay">載入中...</td>
</tr>
</table>
<button class="btn-connect" id="connectBtn">開始使用訪客網路</button>
</div>
<form id="regform"
action="https://securelogin.arubanetworks.com/cgi-bin/login"
method="post"
style="display:none;">
<input type="text" id="email" name="email" placeholder="example@host.com">
<input type="hidden" name="cmd" value="authenticate"/>
<input type="hidden" name="requested_url" value=""/>
</form>
<script>
// 解析 URL 參數
const params = new URLSearchParams(window.location.search);
const essid = params.get("essid") || "無法取得";
const ip = params.get("ip") || "無法取得";
const mac = params.get("mac") || "無法取得";
// 顯示連線資訊
document.getElementById("ssidDisplay").textContent = essid;
document.getElementById("ipDisplay").textContent = ip;
document.getElementById("macDisplay").textContent = mac;
// 一鍵連線動作
document.getElementById("connectBtn").addEventListener("click", () => {
// 組出 yyyymmddhhMM 格式
const now = new Date();
const YYYY = now.getFullYear();
const MM = String(now.getMonth()+1).padStart(2,'0');
const DD = String(now.getDate()).padStart(2,'0');
const hh = String(now.getHours()).padStart(2,'0');
const mm = String(now.getMinutes()).padStart(2,'0');
const ts = `${YYYY}${MM}${DD}${hh}${mm}`;
//預設email
const emailInput = document.getElementById('email');
emailInput.value = ts + '@wifi.guest';
document.querySelector('[name="requested_url"]').value = window.location.href;
document.getElementById("regform").submit();
});
</script>
</body>
</html>
選取檔案之後,按下Submit上傳。
上傳順序:「圖片&相關檔案 >>>>> HTML」。總之,HTML是最後一個上傳。
上傳的檔案不會自動刪除,名稱相同的話則會覆蓋過去。一定要按下Submit之後,等上傳完成,才能上傳下一個檔案。

之後測試WLAN,連上後跳出需要登入的通知,順利看到Captive Portal。

常見的Captive Portal規劃
使用訪客帳密:使用特定一組帳密或是用腳本每天變動。
僅需同意條款:公共提供網路服務,不需使用者額外輸入身份資訊。
我認為提供訪客用無線網路,應該要簡單方便使用,僅需同意條款的類型也在個資意識抬頭上越來越多。
以下提供的範本是部署上最簡易的,僅需建立訪客WLAN,Type「EMAIL」,然後上傳HTML即可。
Type 「EMAIL」。使用email並且透過script讓登入時間去組成email並作為username。
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>無線訪客網路登入</title>
<style>
/* 全域重置與排版 */
* { margin:0; padding:0; box-sizing:border-box; }
body {
display:flex;
align-items:center;
justify-content:center;
height:100vh;
background:#f0f2f5;
font-family:"Microsoft JhengHei",sans-serif;
color:#333;
}
.card {
background:#fff;
border-radius:12px;
box-shadow:0 4px 12px rgba(0,0,0,0.1);
padding:2rem;
max-width:360px;
width:90%;
text-align:center;
}
.card h1 {
font-size:1.5rem;
margin-bottom:1rem;
}
/* 表格對齊 */
.info-table {
width:100%;
border-collapse:collapse;
margin-bottom:1.5rem;
}
.info-table td {
padding:0.5rem 0.25rem;
font-size:0.95rem;
vertical-align:middle;
}
.info-table .label {
width:4em;
text-align:left;
white-space:nowrap; /* 避免換行 */
}
.info-table .value {
text-align:left;
word-break:break-all;
}
/* 按鈕及脈衝動畫 */
@keyframes pulse {
0%,100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
.btn-connect {
display:block;
width:100%;
padding:0.75rem 0;
font-size:1rem;
border:none;
border-radius:8px;
background:#2a4099;
color:#fff;
cursor:pointer;
transition:background 0.3s;
animation:pulse 2s ease-in-out infinite;
}
.btn-connect:hover {
background:#1f3077;
animation-play-state:paused;
}
</style>
</head>
<body>
<div class="card">
<h1>無線訪客網路</h1>
<table class="info-table">
<tr>
<td class="label">SSID:</td>
<td class="value" id="ssidDisplay">載入中...</td>
</tr>
<tr>
<td class="label">IP 位址:</td>
<td class="value" id="ipDisplay">載入中...</td>
</tr>
<tr>
<td class="label">MAC 位址:</td>
<td class="value" id="macDisplay">載入中...</td>
</tr>
</table>
<button class="btn-connect" id="connectBtn">開始使用訪客網路</button>
</div>
<form id="regform"
action="https://securelogin.arubanetworks.com/cgi-bin/login"
method="post"
style="display:none;">
<input type="text" id="email" name="email" placeholder="example@host.com">
<input type="hidden" name="cmd" value="authenticate"/>
<input type="hidden" name="requested_url" value=""/>
</form>
<script>
// 解析 URL 參數
const params = new URLSearchParams(window.location.search);
const essid = params.get("essid") || "無法取得";
const ip = params.get("ip") || "無法取得";
const mac = params.get("mac") || "無法取得";
// 顯示連線資訊
document.getElementById("ssidDisplay").textContent = essid;
document.getElementById("ipDisplay").textContent = ip;
document.getElementById("macDisplay").textContent = mac;
// 一鍵連線動作
document.getElementById("connectBtn").addEventListener("click", () => {
// 組出 yyyymmddhhMM 格式
const now = new Date();
const YYYY = now.getFullYear();
const MM = String(now.getMonth()+1).padStart(2,'0');
const DD = String(now.getDate()).padStart(2,'0');
const hh = String(now.getHours()).padStart(2,'0');
const mm = String(now.getMinutes()).padStart(2,'0');
const ts = `${YYYY}${MM}${DD}${hh}${mm}`;
//預設email
const emailInput = document.getElementById('email');
emailInput.value = ts + '@wifi.guest';
document.querySelector('[name="requested_url"]').value = window.location.href;
document.getElementById("regform").submit();
});
</script>
</body>
</html>
