Compare commits

...

8 Commits

Author SHA1 Message Date
legiz-ru
87e06ffc67
add bind option (#17)
* add bind option
2025-04-06 14:03:37 +10:00
a61bb1718e
Update example-android.json 2025-04-02 19:23:39 +11:00
07f083113b
Update example-windows.json 2025-04-02 19:23:13 +11:00
59ccf8a3c7 modify default templates 2025-02-12 23:19:28 +08:00
legiz-ru
273856968a
Update index.html
fix nodes input
2025-02-08 23:59:58 +03:00
legiz-ru
556fe2d291
Update index.html
fix rename-node for zh-CN
2025-02-08 22:20:14 +03:00
legiz-ru
93c1361a92
enhacments for html page
add language selector
chooser system language
add import and copy button 
fix for using window.location.pathname

code create via github copilot (o3-mini Azure OpenAI)
2025-02-08 22:06:01 +03:00
c0db1624d9 fix missing outbounds 2025-01-20 18:00:14 +08:00
20 changed files with 485 additions and 526 deletions

View File

@ -15,7 +15,7 @@ import (
//go:embed static //go:embed static
var staticFiles embed.FS var staticFiles embed.FS
func RunServer(port uint16) { func RunServer(bind string, port uint16) {
tpl, err := template.ParseFS(staticFiles, "static/*") tpl, err := template.ParseFS(staticFiles, "static/*")
if err != nil { if err != nil {
println(err.Error()) println(err.Error())
@ -41,8 +41,9 @@ func RunServer(port uint16) {
r.GET("/convert", handler.Convert) r.GET("/convert", handler.Convert)
fmt.Println("Server is running on port", port) address := bind + ":" + strconv.Itoa(int(port))
err = r.Run(":" + strconv.Itoa(int(port))) fmt.Println("Server is running on", address)
err = r.Run(address)
if err != nil { if err != nil {
fmt.Println("Run server failed: ", err) fmt.Println("Run server failed: ", err)
} }

View File

@ -1,19 +1,24 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN" class="mdui-theme-auto"> <html lang="en" class="mdui-theme-auto">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>sub2sing-box</title> <title>sub2sing-box</title>
<!-- Include Material Design UI CSS -->
<link rel="stylesheet" href="https://unpkg.com/mdui@2/mdui.css" /> <link rel="stylesheet" href="https://unpkg.com/mdui@2/mdui.css" />
<!-- Include Google Fonts with Chinese support -->
<link href="https://fonts.googleapis.com/css?family=Roboto|Noto+Sans+SC&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" /> <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
<link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet" /> <link href="https://fonts.googleapis.com/icon?family=Material+Icons+Outlined" rel="stylesheet" />
<script src="https://unpkg.com/mdui@2/mdui.global.js"></script> <script src="https://unpkg.com/mdui@2/mdui.global.js"></script>
<style> <style>
/* Apply a uniform font across Chinese, Russian, and English */
body {
font-family: "Roboto", "Noto Sans SC", sans-serif;
}
h3 { h3 {
margin-top: 0; margin-top: 0;
} }
.section { .section {
margin: 20px 0; margin: 20px 0;
padding: 15px; padding: 15px;
@ -21,232 +26,351 @@
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
background-color: rgb(var(--mdui-color-surface-container)); background-color: rgb(var(--mdui-color-surface-container));
} }
.section > * {
.section>* {
margin-bottom: 10px; margin-bottom: 10px;
} }
.mdui-container { .mdui-container {
border-radius: 10px; border-radius: 10px;
max-width: 1200px; max-width: 1200px;
margin: 30px auto; margin: 30px auto;
} }
.header-controls {
display: flex;
gap: 10px;
align-items: center;
}
/* Rename fields: now stacked in a wide layout */
.rename-group {
display: flex;
flex-direction: column;
gap: 10px;
}
</style> </style>
</head> </head>
<body> <body>
<div class="mdui-container"> <div class="mdui-container">
<div style=" <div style="display: flex; align-items: center; justify-content: space-between;">
display: flex;
align-items: center;
justify-content: space-between;
">
<h2> <h2>
<a href="https://github.com/nitezs/sub2sing-box" target="_blank">sub2sing-box</a> <a href="https://github.com/nitezs/sub2sing-box" target="_blank">sub2sing-box</a>
</h2> </h2>
<mdui-dropdown trigger="hover"> <div class="header-controls">
<mdui-icon slot="trigger" name="dark_mode"></mdui-icon> <!-- Language switcher -->
<mdui-menu id="theme" selects="single" value="dark"> <mdui-dropdown trigger="hover">
<mdui-menu-item value="light">亮色</mdui-menu-item> <mdui-icon slot="trigger" name="language"></mdui-icon>
<mdui-menu-item value="dark">暗色</mdui-menu-item> <mdui-menu id="language" selects="single">
<mdui-divider></mdui-divider> <mdui-menu-item value="zh-CN">中文</mdui-menu-item>
<mdui-menu-item value="auto">跟随系统</mdui-menu-item> <mdui-menu-item value="en">English</mdui-menu-item>
</mdui-menu> <mdui-menu-item value="ru">Русский</mdui-menu-item>
</mdui-dropdown> </mdui-menu>
</mdui-dropdown>
<!-- Theme switcher -->
<mdui-dropdown trigger="hover">
<mdui-icon slot="trigger" name="dark_mode"></mdui-icon>
<mdui-menu id="theme" selects="single" value="dark">
<mdui-menu-item value="light" data-i18n="theme-light">Light</mdui-menu-item>
<mdui-menu-item value="dark" data-i18n="theme-dark">Dark</mdui-menu-item>
<mdui-divider></mdui-divider>
<mdui-menu-item value="auto" data-i18n="theme-auto">Auto</mdui-menu-item>
</mdui-menu>
</mdui-dropdown>
</div>
</div> </div>
<!-- Template Section --> <!-- Template Section -->
<div class=" section"> <div class="section">
<h3><a href="https://github.com/nitezs/sub2sing-box/tree/master/templates">模板</a></h3> <h3>
<mdui-text-field label="模板" type="text" id="template" name="template" <a href="https://github.com/nitezs/sub2sing-box/tree/master/templates" data-i18n="template">Template</a>
value="https://raw.githubusercontent.com/nitezs/sub2sing-box/refs/heads/master/templates/example-windows.json" /> </h3>
<mdui-text-field data-i18n="template" label="Template" type="text" id="template" name="template"
value="https://raw.githubusercontent.com/nitezs/sub2sing-box/refs/heads/master/templates/example-windows.json">
</mdui-text-field>
</div> </div>
<div id="form"> <div id="form">
<!-- Nodes Section --> <!-- Nodes Section -->
<div class="section"> <div class="section">
<h3>节点</h3> <h3 data-i18n="nodes">Nodes</h3>
<div> <div>
<mdui-text-field label="订阅链接" autosize min-rows="3" max-rows="5" id="subscription" name="subscription" <mdui-text-field autosize min-rows="3" max-rows="5" data-i18n="subscription-link"
placeholder="一行一个"></mdui-text-field> data-i18n-placeholder="one-per-line"
label="Subscription Link" type="text" id="subscription" name="subscription"
placeholder="One per line">
</mdui-text-field>
</div> </div>
<div> <div>
<mdui-text-field label="节点分享链接" autosize min-rows="3" max-rows="5" id="proxy" name="proxy" <mdui-text-field autosize min-rows="3" max-rows="5" data-i18n="proxy-link"
placeholder="一行一个"></mdui-text-field> data-i18n-placeholder="one-per-line"
label="Node Share Link" type="text" id="proxy" name="proxy"
placeholder="One per line">
</mdui-text-field>
</div> </div>
<div> <div>
<mdui-text-field label="删除节点" type="text" id="delete" name="delete" placeholder="支持正则表达式" /> <mdui-text-field data-i18n="delete-node"
data-i18n-placeholder="supports-regex"
label="Delete Node" type="text" id="delete" name="delete"
placeholder="Supports regex">
</mdui-text-field>
</div> </div>
<div> <div>
<mdui-list id="list"> <mdui-list id="list">
<mdui-list-item> <mdui-list-item>
重命名节点 <span data-i18n="rename-node">Rename Node</span>
<mdui-button slot="end-icon" type="button" onclick="addRenameField()">+ <mdui-button slot="end-icon" type="button" onclick="addRenameField()">+</mdui-button>
</mdui-button>
</mdui-list-item> </mdui-list-item>
</mdui-list> </mdui-list>
<div id="renameContainer"></div> <div id="renameContainer"></div>
</div> </div>
</div> </div>
<!-- Policy Group Section --> <!-- Policy Group Section -->
<div class="section"> <div class="section">
<h3>策略组</h3> <h3 data-i18n="policy-group">Policy Group</h3>
<div> <div>
<mdui-text-field label="类型" type="text" id="group-type" name="group-type" value="selector" /> <mdui-text-field data-i18n="type" label="Type" type="text" id="group-type" name="group-type"
value="selector">
</mdui-text-field>
</div> </div>
<div> <div>
<mdui-select label="排序依据" name="sort" id="sort" value="tag"> <mdui-select data-i18n="sort-by" label="Sort By" name="sort" id="sort" value="tag">
<mdui-menu-item value="tag" selected>节点名</mdui-menu-item> <mdui-menu-item value="tag" selected data-i18n="node-name">Node Name</mdui-menu-item>
<mdui-menu-item value="num">节点数量</mdui-menu-item> <mdui-menu-item value="num" data-i18n="node-count">Node Count</mdui-menu-item>
</mdui-select> </mdui-select>
</div> </div>
<div> <div>
<mdui-select label="排序方式" name="sort-type" id="sort-type" value="asc"> <mdui-select data-i18n="sort-order" label="Sort Order" name="sort-type" id="sort-type" value="asc">
<mdui-menu-item value="asc" selected>升序</mdui-menu-item> <mdui-menu-item value="asc" selected data-i18n="ascending">Ascending</mdui-menu-item>
<mdui-menu-item value="desc">降序</mdui-menu-item> <mdui-menu-item value="desc" data-i18n="descending">Descending</mdui-menu-item>
</mdui-select> </mdui-select>
</div> </div>
</div> </div>
<!-- Result Section --> <!-- Result Section -->
<div class="section"> <div class="section">
<mdui-text-field label="生成链接" autosize min-rows="2" max-rows="5" id="output" name="output"></mdui-text-field> <mdui-text-field data-i18n="generate-link" label="Generate Link" type="text" id="output" name="output">
<!-- Integrated copy icon inside the Generate Link text field -->
<mdui-icon slot="end-icon" onclick="copyLink()" style="cursor:pointer;">content_copy</mdui-icon>
</mdui-text-field>
<mdui-button color="primary" onclick="openProfileLink()">
<span data-i18n="import-profile">Import Profile</span>
</mdui-button>
</div> </div>
</div> </div>
</div> </div>
<script> <script>
init(); // Translation object (v11 parameters)
const translations = {
"zh-CN": {
"template": "模板",
"nodes": "节点",
"subscription-link": "订阅链接",
"proxy-link": "节点分享链接",
"delete-node": "删除节点",
"rename-node": "重命名节点",
"policy-group": "策略组",
"type": "类型",
"sort-by": "排序依据",
"node-name": "节点名",
"node-count": "节点数量",
"sort-order": "排序方式",
"ascending": "升序",
"descending": "降序",
"generate-link": "生成链接",
"theme-light": "亮色",
"theme-dark": "暗色",
"theme-auto": "跟随系统",
"import-profile": "导入配置",
"one-per-line": "一行一个",
"supports-regex": "支持正则表达式"
},
"en": {
"template": "Template",
"nodes": "Nodes",
"subscription-link": "Subscription Link",
"proxy-link": "Node Share Link",
"delete-node": "Delete Node",
"rename-node": "Rename Node",
"policy-group": "Policy Group",
"type": "Type",
"sort-by": "Sort By",
"node-name": "Node Name",
"node-count": "Node Count",
"sort-order": "Sort Order",
"ascending": "Ascending",
"descending": "Descending",
"generate-link": "Generate Link",
"theme-light": "Light",
"theme-dark": "Dark",
"theme-auto": "Auto",
"import-profile": "Import Profile",
"one-per-line": "One per line",
"supports-regex": "Supports regex"
},
"ru": {
"template": "Шаблон",
"nodes": "Узлы",
"subscription-link": "Ссылка подписки",
"proxy-link": "Ссылка на узел",
"delete-node": "Удалить узел",
"rename-node": "Переименовать узел",
"policy-group": "Группа политик",
"type": "Тип",
"sort-by": "Сортировать по",
"node-name": "Имя узла",
"node-count": "Количество узлов",
"sort-order": "Порядок сортировки",
"ascending": "По возрастанию",
"descending": "По убыванию",
"generate-link": "Создать ссылку",
"theme-light": "Светлая",
"theme-dark": "Темная",
"theme-auto": "Системная",
"import-profile": "Импортировать профиль",
"one-per-line": "Одна на строку",
"supports-regex": "Поддерживает рег. выражения"
}
};
function init() { document.addEventListener('DOMContentLoaded', function() {
theme(); setupLanguage();
setupTheme();
listenInput(); listenInput();
// Do not auto-generate link on page load to keep Generate Link empty
});
function setupLanguage() {
const languageMenu = document.getElementById('language');
if (languageMenu) {
const userLang = navigator.language || navigator.userLanguage;
const savedLang = localStorage.getItem('language') ||
(userLang.includes('zh') ? 'zh-CN' : userLang.includes('ru') ? 'ru' : 'en');
languageMenu.value = savedLang;
changeLanguage(savedLang);
languageMenu.addEventListener('change', function() {
changeLanguage(languageMenu.value);
});
}
} }
function theme() { function changeLanguage(lang) {
const theme = window.localStorage.getItem("theme"); localStorage.setItem('language', lang);
if (theme) { document.documentElement.setAttribute('lang', lang);
document.querySelector("html").className = "mdui-theme-" + theme;
document.getElementById("theme").value = theme;
} else {
document.querySelector("html").className = "mdui-theme-auto";
document.getElementById("theme").value = "auto";
window.localStorage.setItem("theme", "auto");
}
const icon = document.getElementById("theme");
const html = document.querySelector("html");
icon.addEventListener("change", function (e) {
switch (icon.value) {
case "light":
html.className = "mdui-theme-light";
window.localStorage.setItem("theme", "light");
break;
case "dark":
html.className = "mdui-theme-dark";
window.localStorage.setItem("theme", "dark");
break;
case "auto":
html.className = "mdui-theme-auto";
window.localStorage.setItem("theme", "auto");
break;
}
document.querySelectorAll('[data-i18n]').forEach(element => {
const key = element.getAttribute('data-i18n');
const translation = translations[lang][key];
if (translation) {
if (element.tagName.toLowerCase() === 'mdui-text-field' ||
element.tagName.toLowerCase() === 'mdui-select') {
element.setAttribute('label', translation);
} else {
element.textContent = translation;
}
}
});
document.querySelectorAll('mdui-text-field').forEach(element => {
const phKey = element.getAttribute('data-i18n-placeholder');
if (phKey && translations[lang][phKey]) {
element.setAttribute('placeholder', translations[lang][phKey]);
}
}); });
} }
function encodeBase64(str) { function setupTheme() {
return btoa( const themeMenu = document.getElementById('theme');
encodeURIComponent(str).replace( const savedTheme = localStorage.getItem("theme") || "auto";
/%([0-9A-F]{2})/g, document.documentElement.className = "mdui-theme-" + savedTheme;
function (match, p1) { themeMenu.value = savedTheme;
return String.fromCharCode("0x" + p1);
}
)
)
.replace(/\+/g, "-")
.replace(/\//g, "_");
}
function decodeBase64(str) { themeMenu.addEventListener("change", function() {
return decodeURIComponent( const newTheme = themeMenu.value;
Array.prototype.map document.documentElement.className = "mdui-theme-" + newTheme;
.call(atob(str), function (c) { localStorage.setItem("theme", newTheme);
return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); });
})
.join("")
);
} }
function listenInput() { function listenInput() {
const inputs = document.querySelectorAll( const inputs = document.querySelectorAll("mdui-text-field, mdui-select, mdui-checkbox");
"mdui-checkbox, mdui-text-field" inputs.forEach(input => {
);
for (let input of inputs) {
input.addEventListener("input", generateLink); input.addEventListener("input", generateLink);
} input.addEventListener("change", generateLink);
const selects = document.querySelectorAll("mdui-select"); });
for (let select of selects) {
select.addEventListener("change", generateLink);
}
} }
function cleanLisnter() { function cleanListeners() {
const inputs = document.querySelectorAll("#form input, #form textarea"); const inputs = document.querySelectorAll("#form input, #form textarea");
for (let input of inputs) { inputs.forEach(input => {
input.removeEventListener("input", generateLink); input.removeEventListener("input", generateLink);
} });
const selects = document.querySelectorAll("#form select"); const selects = document.querySelectorAll("#form select");
for (let select of selects) { selects.forEach(select => {
select.removeEventListener("change", generateLink); select.removeEventListener("change", generateLink);
} });
} }
// Rename field parameters from v11 - fields are now wide (stacked)
function addRenameField() { function addRenameField() {
cleanLisnter(); cleanListeners();
const container = document.getElementById("list"); const container = document.getElementById("list");
const fieldHTML = `<mdui-list-item class="rename-group"> const currentLang = localStorage.getItem('language') || 'en';
<mdui-text-field slot="icon" type="text" name="rename_from[]" placeholder="支持正则表达式" label="原字符"></mdui-text-field> const fieldHTML = `
<mdui-text-field type="text" name="rename_to[]" label="替换字符"></mdui-text-field> <mdui-list-item class="rename-group">
<mdui-button slot="end-icon" type="button" class="btn btn-danger" onclick="removeThisField(this)">-</mdui-button> <mdui-text-field type="text" name="rename_from[]" data-i18n-placeholder="delete-node"
</mdui-list-item>`; label="${translations[currentLang]['delete-node']}" data-i18n="rename-node">
</mdui-text-field>
<mdui-text-field type="text" name="rename_to[]"
label="${translations[currentLang]['rename-node']}" data-i18n="rename-node">
</mdui-text-field>
<mdui-button slot="end-icon" type="button" onclick="removeThisField(this)">-</mdui-button>
</mdui-list-item>`;
container.insertAdjacentHTML("beforeend", fieldHTML); container.insertAdjacentHTML("beforeend", fieldHTML);
listenInput(); listenInput();
} }
function removeThisField(button) { function removeThisField(button) {
cleanLisnter(); cleanListeners();
button.parentElement.remove(); button.parentElement.remove();
generateLink(); generateLink();
listenInput(); listenInput();
} }
function generateLink() { function generateLink() {
const subscription = document const subscription = document.getElementById("subscription").value
.getElementById("subscription") .split("\n").filter(i => i.trim());
.value.split("\n") const proxy = document.getElementById("proxy").value
.filter((i) => i); .split("\n").filter(i => i.trim());
const proxy = document
.getElementById("proxy")
.value.split("\n")
.filter((i) => i);
const deleteRule = document.getElementById("delete").value; const deleteRule = document.getElementById("delete").value;
const template = document.getElementById("template").value; const template = document.getElementById("template").value;
const renameFrom = Array.from( const renameFrom = Array.from(document.getElementsByName("rename_from[]")).map(input => input.value);
document.getElementsByName("rename_from[]") const renameTo = Array.from(document.getElementsByName("rename_to[]")).map(input => input.value);
).map((input) => input.value);
const renameTo = Array.from(
document.getElementsByName("rename_to[]")
).map((input) => input.value);
const output = document.getElementById("output"); const output = document.getElementById("output");
const groupType = document.getElementById("group-type").value; const groupType = document.getElementById("group-type").value;
const sort = document.getElementById("sort").value; const sort = document.getElementById("sort").value;
const sortType = document.getElementById("sort-type").value; const sortType = document.getElementById("sort-type").value;
let rename = {}; let rename = {};
for (let i = 0; i < renameFrom.length; i++) { for (let i = 0; i < renameFrom.length; i++) {
if (renameFrom[i] && renameTo[i]) { if (renameFrom[i] && renameTo[i]) {
rename[renameFrom[i]] = renameTo[i]; rename[renameFrom[i]] = renameTo[i];
} }
} }
// Only generate link if there is some user input (subscription, proxy, delete, or rename)
if (
subscription.length === 0 &&
proxy.length === 0 &&
deleteRule.trim() === "" &&
Object.keys(rename).length === 0
) {
output.value = "";
return;
}
// Determine base URL using window.location.origin and window.location.pathname
let basePath = window.location.pathname;
// Remove trailing slash if exists
if(basePath.endsWith('/')){
basePath = basePath.slice(0, -1);
}
const baseURL = window.location.origin + basePath;
const data = { const data = {
subscription, subscription,
proxy, proxy,
@ -255,14 +379,43 @@
rename, rename,
"group-type": groupType, "group-type": groupType,
sort, sort,
"sort-type": sortType, "sort-type": sortType
}; };
output.value = baseURL + "/convert?data=" + encodeBase64(JSON.stringify(data));
}
output.value = `${window.location.origin}/convert?data=${encodeBase64( function encodeBase64(str) {
JSON.stringify(data) return btoa(
)}`; encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function(match, p1) {
return String.fromCharCode("0x" + p1);
})
).replace(/\+/g, "-").replace(/\//g, "_");
}
function openProfileLink() {
const output = document.getElementById("output").value;
if (output) {
window.location.href = "sing-box://import-remote-profile?url=" + encodeURIComponent(output);
} else {
alert("Output link is empty");
}
}
// Integrated copy function for the Generate Link field
function copyLink() {
const output = document.getElementById("output").value;
if (output) {
navigator.clipboard.writeText(output)
.then(() => {
mdui.snackbar({ message: "Link copied!", position: "top" });
})
.catch(err => {
mdui.snackbar({ message: "Failed to copy link!", position: "top" });
});
} else {
mdui.snackbar({ message: "Output link is empty", position: "top" });
}
} }
</script> </script>
</body> </body>
</html>
</html>

View File

@ -6,10 +6,14 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var port uint16 var (
port uint16
bind string
)
func init() { func init() {
runCmd.Flags().Uint16VarP(&port, "port", "p", 8080, "server port") runCmd.Flags().Uint16VarP(&port, "port", "p", 8080, "server port")
runCmd.Flags().StringVarP(&bind, "bind", "b", "0.0.0.0", "bind address (e.g., 0.0.0.0, 127.0.0.1, localhost)")
RootCmd.AddCommand(runCmd) RootCmd.AddCommand(runCmd)
} }
@ -18,6 +22,6 @@ var runCmd = &cobra.Command{
Long: "Run the server", Long: "Run the server",
Short: "Run the server", Short: "Run the server",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
api.RunServer(port) api.RunServer(bind, port)
}, },
} }

View File

@ -38,6 +38,10 @@ func Convert(
result := "" result := ""
var err error var err error
if groupType == "" {
groupType = C.TypeSelector
}
outbounds, err := ConvertSubscriptionsToSProxy(subscriptions) outbounds, err := ConvertSubscriptionsToSProxy(subscriptions)
if err != nil { if err != nil {
return "", err return "", err
@ -116,6 +120,15 @@ func Convert(
if template, err = J.UnmarshalExtendedContext[model.Options](globalCtx, []byte(templateData)); err != nil { if template, err = J.UnmarshalExtendedContext[model.Options](globalCtx, []byte(templateData)); err != nil {
return "", err return "", err
} }
for _, v := range template.Options.Outbounds {
template.Outbounds = append(template.Outbounds, (model.Outbound)(v))
}
for _, v := range template.Options.Inbounds {
template.Inbounds = append(template.Inbounds, (model.Inbound)(v))
}
for _, v := range template.Options.Endpoints {
template.Endpoints = append(template.Endpoints, (model.Endpoint)(v))
}
result, err = MergeTemplate(outbounds, &template) result, err = MergeTemplate(outbounds, &template)
if err != nil { if err != nil {
return "", err return "", err
@ -147,7 +160,7 @@ func AddCountryGroup(proxies []model.Outbound, groupType string, sortKey string,
AppendOutbound(&group, p.Tag) AppendOutbound(&group, p.Tag)
newGroup[country] = group newGroup[country] = group
} else { } else {
if groupType == C.TypeSelector || groupType == "" { if groupType == C.TypeSelector {
newGroup[country] = model.Outbound{ newGroup[country] = model.Outbound{
Tag: country, Tag: country,
Type: groupType, Type: groupType,
@ -173,23 +186,25 @@ func AddCountryGroup(proxies []model.Outbound, groupType string, sortKey string,
for _, p := range newGroup { for _, p := range newGroup {
groups = append(groups, p) groups = append(groups, p)
} }
if sortType == "asc" { if sortType != "" {
switch sortKey { if sortType == "asc" {
case "tag": switch sortKey {
sort.Sort(model.SortByTag(groups)) case "tag":
case "num": sort.Sort(model.SortByTag(groups))
sort.Sort(model.SortByNumber(groups)) case "num":
default: sort.Sort(model.SortByNumber(groups))
sort.Sort(model.SortByTag(groups)) default:
} sort.Sort(model.SortByTag(groups))
} else { }
switch sortKey { } else {
case "tag": switch sortKey {
sort.Sort(sort.Reverse(model.SortByTag(groups))) case "tag":
case "num": sort.Sort(sort.Reverse(model.SortByTag(groups)))
sort.Sort(sort.Reverse(model.SortByNumber(groups))) case "num":
default: sort.Sort(sort.Reverse(model.SortByNumber(groups)))
sort.Sort(sort.Reverse(model.SortByTag(groups))) default:
sort.Sort(sort.Reverse(model.SortByTag(groups)))
}
} }
} }
return append(proxies, groups...) return append(proxies, groups...)
@ -236,7 +251,8 @@ func MergeTemplate(outbounds []model.Outbound, template *model.Options) (string,
} }
} }
reg := regexp.MustCompile("<[A-Za-z]{2}>") reg := regexp.MustCompile("<[A-Za-z]{2}>")
for i, outbound := range template.Outbounds { for i, o := range template.Outbounds {
outbound := (model.Outbound)(o)
if outbound.Type == C.TypeSelector || outbound.Type == C.TypeURLTest { if outbound.Type == C.TypeSelector || outbound.Type == C.TypeURLTest {
var parsedOutbound []string = make([]string, 0) var parsedOutbound []string = make([]string, 0)
for _, o := range GetOutbounds(&outbound) { for _, o := range GetOutbounds(&outbound) {
@ -349,6 +365,12 @@ func SetOutbounds(outbound *model.Outbound, outbounds []string) {
case option.URLTestOutboundOptions: case option.URLTestOutboundOptions:
v.Outbounds = outbounds v.Outbounds = outbounds
outbound.Options = v outbound.Options = v
case *option.SelectorOutboundOptions:
v.Outbounds = outbounds
outbound.Options = v
case *option.URLTestOutboundOptions:
v.Outbounds = outbounds
outbound.Options = v
} }
} }
@ -360,6 +382,12 @@ func AppendOutbound(outbound *model.Outbound, outboundTag string) {
case option.URLTestOutboundOptions: case option.URLTestOutboundOptions:
v.Outbounds = append(v.Outbounds, outboundTag) v.Outbounds = append(v.Outbounds, outboundTag)
outbound.Options = v outbound.Options = v
case *option.SelectorOutboundOptions:
v.Outbounds = append(v.Outbounds, outboundTag)
outbound.Options = v
case *option.URLTestOutboundOptions:
v.Outbounds = append(v.Outbounds, outboundTag)
outbound.Options = v
} }
} }
@ -369,6 +397,10 @@ func GetOutbounds(outbound *model.Outbound) []string {
return v.Outbounds return v.Outbounds
case option.URLTestOutboundOptions: case option.URLTestOutboundOptions:
return v.Outbounds return v.Outbounds
case *option.SelectorOutboundOptions:
return v.Outbounds
case *option.URLTestOutboundOptions:
return v.Outbounds
} }
return nil return nil
} }

View File

@ -1,4 +1,4 @@
#!/bin/bash #!/bin/bash
sing-box merge -C ./templates/windows ./templates/example-windows.json sing-box merge -C ./templates/windows ./templates/example-windows.json
sing-box merge -C ./templates/linux ./templates/example-linux.json sing-box merge -C ./templates/linux ./templates/example-linux.json
sing-box merge -C ./templates/android ./templates/example-android.json sing-box merge -C ./templates/windows ./templates/example-android.json

View File

@ -1,60 +0,0 @@
{
"dns": {
"servers": [
{
"tag": "google",
"address": "https://8.8.8.8/dns-query",
"detour": "default"
},
{
"tag": "local",
"address": "h3://223.5.5.5/dns-query",
"detour": "direct"
},
{
"tag": "refused",
"address": "rcode://refused"
}
],
"rules": [
{
"rule_set": "geosite-category-ads-all",
"server": "refused"
},
{
"outbound": "any",
"server": "local"
},
{
"clash_mode": "Direct",
"server": "local"
},
{
"clash_mode": "Global",
"server": "google"
},
{
"rule_set": "geosite-geolocation-cn",
"server": "local"
},
{
"type": "logical",
"mode": "and",
"rules": [
{
"rule_set": "geosite-geolocation-!cn",
"invert": true
},
{
"rule_set": "geoip-cn"
}
],
"server": "google",
"client_subnet": "114.114.114.114/24"
}
],
"final": "google",
"disable_cache": false,
"disable_expire": false
}
}

View File

@ -1,13 +0,0 @@
{
"experimental": {
"cache_file": {
"enabled": true
},
"clash_api": {
"external_controller": "127.0.0.1:9090",
"external_ui": "./ui",
"external_ui_download_detour": "default",
"default_mode": "Enhanced"
}
}
}

View File

@ -1,12 +0,0 @@
{
"inbounds": [
{
"type": "tun",
"auto_route": true,
"strict_route": true,
"sniff": true,
"address": ["172.16.0.1/30", "fdfe:dcba:9876::1/126"],
"exclude_interface": ["tailscale0"]
}
]
}

View File

@ -1,6 +0,0 @@
{
"log": {
"level": "info",
"timestamp": true
}
}

View File

@ -1,8 +0,0 @@
{
"ntp": {
"enabled": true,
"server": "ntp.aliyun.com",
"server_port": 123,
"interval": "30m"
}
}

View File

@ -1,21 +0,0 @@
{
"outbounds": [
{
"outbounds": ["<all-proxy-tags>", "direct"],
"tag": "default",
"type": "selector"
},
{
"tag": "direct",
"type": "direct"
},
{
"tag": "block",
"type": "block"
},
{
"tag": "dns",
"type": "dns"
}
]
}

View File

@ -1,53 +0,0 @@
{
"route": {
"rules": [
{
"type": "logical",
"mode": "or",
"rules": [
{
"protocol": "dns"
},
{
"port": 53
}
],
"outbound": "dns"
},
{
"ip_is_private": true,
"outbound": "direct"
},
{
"clash_mode": "Direct",
"outbound": "direct"
},
{
"clash_mode": "Global",
"outbound": "default"
},
{
"rule_set": "geosite-google",
"outbound": "default"
},
{
"type": "logical",
"mode": "or",
"rules": [
{
"rule_set": [
"geoip-cn",
"geosite-geolocation-cn",
"geosite-cn",
"geosite-category-games@cn",
"geosite-steam@cn"
]
}
],
"outbound": "direct"
}
],
"final": "default",
"auto_detect_interface": true
}
}

View File

@ -1,62 +0,0 @@
{
"route": {
"rule_set": [
{
"type": "remote",
"tag": "geosite-category-ads-all",
"format": "binary",
"url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs",
"download_detour": "default"
},
{
"type": "remote",
"tag": "geosite-geolocation-cn",
"format": "binary",
"url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-cn.srs",
"download_detour": "default"
},
{
"type": "remote",
"tag": "geosite-geolocation-!cn",
"format": "binary",
"url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-!cn.srs",
"download_detour": "default"
},
{
"type": "remote",
"tag": "geoip-cn",
"format": "binary",
"url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs",
"download_detour": "default"
},
{
"type": "remote",
"tag": "geosite-category-games@cn",
"format": "binary",
"url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-games@cn.srs",
"download_detour": "default"
},
{
"type": "remote",
"tag": "geosite-steam@cn",
"format": "binary",
"url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-steam@cn.srs",
"download_detour": "default"
},
{
"type": "remote",
"tag": "geosite-cn",
"format": "binary",
"url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs",
"download_detour": "default"
},
{
"type": "remote",
"tag": "geosite-google",
"format": "binary",
"url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-google.srs",
"download_detour": "default"
}
]
}
}

View File

@ -90,30 +90,29 @@
{ {
"type": "direct", "type": "direct",
"tag": "direct" "tag": "direct"
},
{
"type": "block",
"tag": "block"
},
{
"type": "dns",
"tag": "dns"
} }
], ],
"route": { "route": {
"rules": [ "rules": [
{ {
"type": "logical", "action": "resolve",
"mode": "or", "strategy": "prefer_ipv4"
"rules": [ },
{ {
"protocol": "dns" "action": "sniff",
}, "timeout": "1s"
{ },
"port": 53 {
} "protocol": "dns",
"action": "hijack-dns"
},
{
"port": [
3478,
5348,
5349
], ],
"outbound": "dns" "outbound": "direct"
}, },
{ {
"ip_is_private": true, "ip_is_private": true,
@ -132,18 +131,19 @@
"outbound": "default" "outbound": "default"
}, },
{ {
"type": "logical", "process_path_regex": [
"mode": "or", "^.+/steam/compatibilitytools.d/.+",
"rules": [ "^.+/steamapps/common/.*"
{ ],
"rule_set": [ "outbound": "direct"
"geoip-cn", },
"geosite-geolocation-cn", {
"geosite-cn", "rule_set": [
"geosite-category-games@cn", "geoip-cn",
"geosite-steam@cn" "geosite-geolocation-cn",
] "geosite-cn",
} "geosite-category-games@cn",
"geosite-steam@cn"
], ],
"outbound": "direct" "outbound": "direct"
} }
@ -216,6 +216,7 @@
"clash_api": { "clash_api": {
"external_controller": "127.0.0.1:9090", "external_controller": "127.0.0.1:9090",
"external_ui": "./ui", "external_ui": "./ui",
"external_ui_download_url": "https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip",
"external_ui_download_detour": "default", "external_ui_download_detour": "default",
"default_mode": "Enhanced" "default_mode": "Enhanced"
} }

View File

@ -68,15 +68,15 @@
"inbounds": [ "inbounds": [
{ {
"type": "tun", "type": "tun",
"tag": "tun-in",
"address": [ "address": [
"172.16.0.1/30", "192.168.135.1/30",
"fdfe:dcba:9876::1/126" "fdfe:dcba:9876::1/126"
], ],
"auto_route": true, "auto_route": true,
"auto_redirect": true, "auto_redirect": true,
"strict_route": true, "strict_route": true,
"exclude_interface": "tailscale0", "route_exclude_address": "100.64.0.0/10"
"sniff": true
} }
], ],
"outbounds": [ "outbounds": [
@ -91,30 +91,29 @@
{ {
"type": "direct", "type": "direct",
"tag": "direct" "tag": "direct"
},
{
"type": "block",
"tag": "block"
},
{
"type": "dns",
"tag": "dns"
} }
], ],
"route": { "route": {
"rules": [ "rules": [
{ {
"type": "logical", "action": "resolve",
"mode": "or", "strategy": "prefer_ipv4"
"rules": [ },
{ {
"protocol": "dns" "action": "sniff",
}, "timeout": "1s"
{ },
"port": 53 {
} "protocol": "dns",
"action": "hijack-dns"
},
{
"port": [
3478,
5348,
5349
], ],
"outbound": "dns" "outbound": "direct"
}, },
{ {
"ip_is_private": true, "ip_is_private": true,
@ -133,18 +132,23 @@
"outbound": "default" "outbound": "default"
}, },
{ {
"type": "logical", "rule_set": "geosite-bahamut",
"mode": "or", "outbound": "台湾(TW)"
"rules": [ },
{ {
"rule_set": [ "process_path_regex": [
"geoip-cn", "^.+/steam/compatibilitytools.d/.+",
"geosite-geolocation-cn", "^.+/steamapps/common/.*"
"geosite-cn", ],
"geosite-category-games@cn", "outbound": "direct"
"geosite-steam@cn" },
] {
} "rule_set": [
"geoip-cn",
"geosite-geolocation-cn",
"geosite-cn",
"geosite-category-games@cn",
"geosite-steam@cn"
], ],
"outbound": "direct" "outbound": "direct"
} }
@ -217,6 +221,7 @@
"clash_api": { "clash_api": {
"external_controller": "127.0.0.1:9090", "external_controller": "127.0.0.1:9090",
"external_ui": "./ui", "external_ui": "./ui",
"external_ui_download_url": "https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip",
"external_ui_download_detour": "default", "external_ui_download_detour": "default",
"default_mode": "Enhanced" "default_mode": "Enhanced"
} }

View File

@ -90,30 +90,29 @@
{ {
"type": "direct", "type": "direct",
"tag": "direct" "tag": "direct"
},
{
"type": "block",
"tag": "block"
},
{
"type": "dns",
"tag": "dns"
} }
], ],
"route": { "route": {
"rules": [ "rules": [
{ {
"type": "logical", "action": "resolve",
"mode": "or", "strategy": "prefer_ipv4"
"rules": [ },
{ {
"protocol": "dns" "action": "sniff",
}, "timeout": "1s"
{ },
"port": 53 {
} "protocol": "dns",
"action": "hijack-dns"
},
{
"port": [
3478,
5348,
5349
], ],
"outbound": "dns" "outbound": "direct"
}, },
{ {
"ip_is_private": true, "ip_is_private": true,
@ -132,18 +131,19 @@
"outbound": "default" "outbound": "default"
}, },
{ {
"type": "logical", "process_path_regex": [
"mode": "or", "^.+/steam/compatibilitytools.d/.+",
"rules": [ "^.+/steamapps/common/.*"
{ ],
"rule_set": [ "outbound": "direct"
"geoip-cn", },
"geosite-geolocation-cn", {
"geosite-cn", "rule_set": [
"geosite-category-games@cn", "geoip-cn",
"geosite-steam@cn" "geosite-geolocation-cn",
] "geosite-cn",
} "geosite-category-games@cn",
"geosite-steam@cn"
], ],
"outbound": "direct" "outbound": "direct"
} }
@ -216,6 +216,7 @@
"clash_api": { "clash_api": {
"external_controller": "127.0.0.1:9090", "external_controller": "127.0.0.1:9090",
"external_ui": "./ui", "external_ui": "./ui",
"external_ui_download_url": "https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip",
"external_ui_download_detour": "default", "external_ui_download_detour": "default",
"default_mode": "Enhanced" "default_mode": "Enhanced"
} }

View File

@ -37,10 +37,6 @@
"rule_set": "geosite-bahamut", "rule_set": "geosite-bahamut",
"outbound": "台湾(TW)" "outbound": "台湾(TW)"
}, },
{
"domain_suffix": "online-fix.me",
"outbound": "香港(HK)"
},
{ {
"process_path_regex": [ "process_path_regex": [
"^.+/steam/compatibilitytools.d/.+", "^.+/steam/compatibilitytools.d/.+",

View File

@ -6,6 +6,7 @@
"clash_api": { "clash_api": {
"external_controller": "127.0.0.1:9090", "external_controller": "127.0.0.1:9090",
"external_ui": "./ui", "external_ui": "./ui",
"external_ui_download_url": "https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip",
"external_ui_download_detour": "default", "external_ui_download_detour": "default",
"default_mode": "Enhanced" "default_mode": "Enhanced"
} }

View File

@ -8,14 +8,6 @@
{ {
"tag": "direct", "tag": "direct",
"type": "direct" "type": "direct"
},
{
"tag": "block",
"type": "block"
},
{
"tag": "dns",
"type": "dns"
} }
] ]
} }

View File

@ -2,17 +2,20 @@
"route": { "route": {
"rules": [ "rules": [
{ {
"type": "logical", "action": "resolve",
"mode": "or", "strategy": "prefer_ipv4"
"rules": [ },
{ {
"protocol": "dns" "action": "sniff",
}, "timeout": "1s"
{ },
"port": 53 {
} "protocol": "dns",
], "action": "hijack-dns"
"outbound": "dns" },
{
"port": [3478, 5348, 5349],
"outbound": "direct"
}, },
{ {
"ip_is_private": true, "ip_is_private": true,
@ -31,18 +34,23 @@
"outbound": "default" "outbound": "default"
}, },
{ {
"type": "logical", "rule_set": "geosite-bahamut",
"mode": "or", "outbound": "台湾(TW)"
"rules": [ },
{ {
"rule_set": [ "process_path_regex": [
"geoip-cn", "^.+/steam/compatibilitytools.d/.+",
"geosite-geolocation-cn", "^.+/steamapps/common/.*"
"geosite-cn", ],
"geosite-category-games@cn", "outbound": "direct"
"geosite-steam@cn" },
] {
} "rule_set": [
"geoip-cn",
"geosite-geolocation-cn",
"geosite-cn",
"geosite-category-games@cn",
"geosite-steam@cn"
], ],
"outbound": "direct" "outbound": "direct"
} }