├── .gitattributes
├── Classic
├── README.md
├── custom.sh
└── webui
│ ├── main.go
│ └── public
│ ├── assets
│ ├── fonts
│ │ ├── Models-Logo.woff2
│ │ ├── Roboto-Bold.woff2
│ │ ├── Roboto-Italic.woff2
│ │ └── Roboto-Regular.woff2
│ └── sprite
│ │ ├── adguard.svg
│ │ ├── check-mark-small.svg
│ │ ├── dashboard.svg
│ │ ├── delete.svg
│ │ ├── donate.svg
│ │ ├── github.svg
│ │ ├── hide-password.svg
│ │ ├── info.svg
│ │ ├── logout.svg
│ │ └── settings.svg
│ ├── index.html
│ ├── login.html
│ ├── scrypt.js
│ └── style.css
├── LICENSE
├── Neo
└── README.md
├── README.md
└── Relic
├── README.md
├── hydraroute.sh
└── webpanel
└── hpanel.tar
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/Classic/README.md:
--------------------------------------------------------------------------------
1 | # HydraRoute Classic
2 |
3 | **HydraRoute Classic** — выбрать куда перенаправить отдельные домены или группу/список доменов можно
4 | просто изменив подключение в политике доступа веб-интерфейса роутера.
5 |
6 | ---
7 |
8 | ## 📚 Оглавление
9 |
10 | - [🚀 Возможности Classic](#-возможности-classic)
11 | - [📋 Системные требования](#-системные-требования)
12 | - [💾 Установка](#-установка)
13 | - [📁 Работа с доменами](#-работа-с-доменами)
14 | - [🔧 Политики доступа](#-политики-доступа)
15 | - [🔄 Обновление](#-обновление)
16 | - [❌ Удаление](#-удаление)
17 | - [ℹ️ Примечания](#️-примечания)
18 | - [☕ Donate](#-donate)
19 |
20 | ---
21 |
22 | ## 🚀 Возможности Classic
23 |
24 | - Перенаправление трафика доменов в VPN.
25 | - Поддержка до 3х политик доступа.
26 | - Изменение подключения для группы доменов без перезапуска.
27 | - Совместимость с WARP.
28 |
29 | ---
30 |
31 | ## 📋 Системные требования
32 |
33 | - Роутер Keenetic с установленным [Entware](https://help.keenetic.com/hc/ru/articles/360021214160)
34 | - Установленный пакет `curl`:
35 | ```
36 | opkg install curl
37 | ```
38 |
39 | ---
40 |
41 | ## 💾 Установка
42 |
43 | 1. 📦 Добавить репозиторий:
44 | ```
45 | curl -Ls "https://ground-zerro.github.io/release/keenetic/install-feed.sh" | sh
46 | ```
47 |
48 | 2. 🚀 Установить HydraRoute Classic:
49 | ```
50 | opkg install hydraroute
51 | ```
52 |
53 | > ⚠️ После установки устройство будет автоматически перезагружено.
54 |
55 | 3. ✅ Настройка после загрузки:
56 | - Веб-интерфейс роутера → **Приоритеты подключений → Политики доступа в интернет**
57 | - Найти политику **HydraRoute1st** и отметить нужное подключение
58 |
59 | ---
60 |
61 | ## 📁 Работа с доменами
62 |
63 | Выбор: через Web-интерфейс **ИЛИ** вручную.
64 |
65 | ### 🖥️ Через Web-интерфейс:
66 |
67 | - Открыть [http://hr.net/](http://hr.net/) или [http://192.168.1.1:2000/](http://192.168.1.1:2000/)
68 | - Пароль по умолчанию: `keenetic`
69 |
70 | ### ✍️ Вручную:
71 |
72 | 1. Открыть файл:
73 | `/opt/etc/AdGuardHome/domain.conf`
74 | ```
75 | nano /opt/etc/AdGuardHome/domain.conf
76 | ```
77 |
78 | 2. Добавить домены:
79 | ```
80 | youtube.com,googlevideo.com/hr1
81 | openai.com,chatgpt.com/hr2
82 | ```
83 |
84 | - Домены разделяются запятой
85 | - После `/` — имя ipset группы (см. таблицу ниже)
86 |
87 | | Политика | ipset |
88 | |:------------------|:------|
89 | | HydraRoute1st | hr1 |
90 | | HydraRoute2nd | hr2 |
91 | | HydraRoute3rd | hr3 |
92 |
93 | 3. 💡 Перезапуск AdGuard Home:
94 | ```
95 | agh restart
96 | ```
97 |
98 | > 👉 Поддомены (`*.google.com`, `*.yandex.ru` etc.) подхватываются автоматически
99 |
100 | ---
101 |
102 | ## 🔧 Политики доступа
103 |
104 | - Политики можно назначать как доменам, так и устройствам.
105 | - При отключении всех подключений в политике — трафик доменов блокируется.
106 | - Порядок туннелей в политике задаёт приоритет переключения при потере связи.
107 |
108 | ---
109 |
110 | ## 🔄 Обновление
111 |
112 | Конмада для обновления установленных пакетов:
113 | ```
114 | opkg update && opkg upgrade
115 | ```
116 |
117 | ---
118 |
119 | ## ❌ Удаление
120 |
121 | Стандартно:
122 | ```
123 | opkg remove hydraroute
124 | ```
125 |
126 | Полное удаление (в т.ч. файлы, логи etc.) c откатом всех изменений в системе к стандартным:
127 | ```
128 | curl -Ls "https://ground-zerro.github.io/release/keenetic/hr-uninstall.sh" | sh
129 | ```
130 |
131 | ---
132 |
133 | ## ℹ️ Примечания
134 |
135 | - Не переименовывайте и не удаляйте политики `HydraRoute` (`1st`, `2nd`, `3rd`), иначе скрипт перестанет работать.
136 |
137 | ---
138 |
139 | ## ☕ Donate
140 |
141 | Если HydraRoute оказался Вам полезным — можно отблагодарить автора:
142 |
143 | - [Угостив](https://boosty.to/ground_zerro/donate) кружечкой горячего какао 😋
144 | - Став [подписчиком](https://boosty.to/ground_zerro)
145 |
--------------------------------------------------------------------------------
/Classic/custom.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # Служебные функции и переменные
4 | LOG="/opt/var/log/HydraRoute.log"
5 | echo "$(date "+%Y-%m-%d %H:%M:%S") Запуск установки КОСТЫЛЯ" >> "$LOG"
6 |
7 | ## анимация
8 | animation() {
9 | local pid=$1
10 | local message=$2
11 | local spin='-\|/'
12 |
13 | echo -n "$message... "
14 |
15 | while kill -0 $pid 2>/dev/null; do
16 | for i in $(seq 0 3); do
17 | echo -ne "\b${spin:$i:1}"
18 | usleep 100000 # 0.1 сек
19 | done
20 | done
21 |
22 | wait $pid
23 | if [ $? -eq 0 ]; then
24 | echo -e "\b✔ Готово!"
25 | else
26 | echo -e "\b✖ Ошибка!"
27 | fi
28 | }
29 |
30 | # Получение списка и выбор интерфейса
31 | get_interfaces() {
32 | ## выводим список интерфейсов для выбора
33 | echo "Доступные интерфейсы:"
34 | i=1
35 | interfaces=$(ip a | sed -n 's/.*: \(.*\): <.*UP.*/\1/p')
36 | interface_list=""
37 | for iface in $interfaces; do
38 | ## проверяем, существует ли интерфейс, игнорируя ошибки 'ip: can't find device'
39 | if ip a show "$iface" &>/dev/null; then
40 | ip_address=$(ip a show "$iface" | grep -oP 'inet \K[\d.]+')
41 |
42 | if [ -n "$ip_address" ]; then
43 | echo "$i. $iface: $ip_address"
44 | interface_list="$interface_list $iface"
45 | i=$((i+1))
46 | fi
47 | fi
48 | done
49 |
50 | ## запрашиваем у пользователя имя интерфейса с проверкой ввода
51 | while true; do
52 | read -p "Введите ИМЯ интерфейса, через которое будет перенаправляться трафик: " net_interface
53 |
54 | if echo "$interface_list" | grep -qw "$net_interface"; then
55 | echo "Выбран интерфейс: $net_interface"
56 | break
57 | else
58 | echo "Неверный выбор, необходимо ввести ИМЯ интерфейса из списка."
59 | fi
60 | done
61 | }
62 |
63 | # Установка пакетов
64 | opkg_install() {
65 | opkg update
66 | opkg install ip-full jq
67 | }
68 |
69 | # Формирование файлов
70 | files_create() {
71 | ## ipset
72 | cat << EOF > /opt/etc/init.d/S52ipset
73 | #!/bin/sh
74 |
75 | PATH=/opt/sbin:/opt/bin:/opt/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
76 |
77 | if [ "\$1" = "start" ]; then
78 | ipset create bypass hash:ip
79 | ip rule add fwmark 1001 table 1001
80 | fi
81 | EOF
82 |
83 | ## скрипты маршрутизации
84 | cat << EOF > /opt/etc/ndm/ifstatechanged.d/010-bypass-table.sh
85 | #!/bin/sh
86 |
87 | [ "\$system_name" == "$net_interface" ] || exit 0
88 | [ ! -z "\$(ipset --quiet list bypass)" ] || exit 0
89 | [ "\${connected}-\${link}-\${up}" == "yes-up-up" ] || exit 0
90 |
91 | if [ -z "\$(ip route list table 1001)" ]; then
92 | ip route add default dev \$system_name table 1001
93 | fi
94 | EOF
95 |
96 | ## cкрипты маркировки трафика
97 | cat << EOF > /opt/etc/ndm/netfilter.d/010-bypass.sh
98 | #!/bin/sh
99 |
100 | [ "\$type" == "ip6tables" ] && exit
101 | [ "\$table" != "mangle" ] && exit
102 | [ -z "\$(ip link list | grep $net_interface)" ] && exit
103 | [ -z "\$(ipset --quiet list bypass)" ] && exit
104 |
105 | iptables -w -t mangle -C PREROUTING ! -i $net_interface -m conntrack --ctstate NEW -m set --match-set bypass dst -j CONNMARK --set-mark 1001 2>/dev/null || \
106 | iptables -w -t mangle -A PREROUTING ! -i $net_interface -m conntrack --ctstate NEW -m set --match-set bypass dst -j CONNMARK --set-mark 1001
107 |
108 | iptables -w -t mangle -C PREROUTING ! -i $net_interface -m set --match-set bypass dst -j CONNMARK --restore-mark 2>/dev/null || \
109 | iptables -w -t mangle -A PREROUTING ! -i $net_interface -m set --match-set bypass dst -j CONNMARK --restore-mark
110 | EOF
111 | }
112 |
113 | # Базовый список доменов для костыля с 3D защитой на всякий случай... ))
114 | domain_add() {
115 | config_file="/opt/etc/AdGuardHome/ipset.conf"
116 | pattern="googlevideo.com\|ggpht.com\|googleapis.com\|googleusercontent.com\|gstatic.com\|nhacmp3youtube.com\|youtu.be\|youtube.com\|ytimg.com"
117 | sed -i "/$pattern/d" "$config_file"
118 | echo "googlevideo.com,ggpht.com,googleapis.com,googleusercontent.com,gstatic.com,nhacmp3youtube.com,youtu.be,youtube.com,ytimg.com/bypass" >> "$config_file"
119 | }
120 |
121 | # Установка прав на скрипты
122 | chmod_set() {
123 | chmod +x /opt/etc/init.d/S52ipset
124 | chmod +x /opt/etc/ndm/ifstatechanged.d/010-bypass-table.sh
125 | chmod +x /opt/etc/ndm/netfilter.d/010-bypass.sh
126 | }
127 |
128 | # Отключение ipv6 на провайдере
129 | disable_ipv6() {
130 | curl -kfsS "localhost:79/rci/show/interface/" | jq -r '
131 | to_entries[] |
132 | select(.value.defaultgw == true or .value.via != null) |
133 | if .value.via then "\(.value.id) \(.value.via)" else "\(.value.id)" end
134 | ' | while read -r iface via; do
135 | ndmc -c "no interface $iface ipv6 address"
136 | if [ -n "$via" ]; then
137 | ndmc -c "no interface $via ipv6 address"
138 | fi
139 | done
140 | ndmc -c 'system configuration save'
141 | }
142 |
143 | # Сообщение установка ОK
144 | complete_info() {
145 | echo "Установка КОСТЫЛЯ завершена"
146 | echo "Нажми Enter для перезагрузки (обязательно)."
147 | }
148 |
149 | # === main ===
150 | # Запрос интерфейса у пользователя
151 | get_interfaces
152 |
153 | # Установка пакетов
154 | opkg_install >>"$LOG" 2>&1 &
155 | animation $! "Установка необходимых пакетов"
156 |
157 | # Формирование скриптов
158 | files_create >>"$LOG" 2>&1 &
159 | animation $! "Формируем скрипты"
160 |
161 | # Добавление YOUTUBE в ipset
162 | domain_add >>"$LOG" 2>&1 &
163 | animation $! "Добавление в ipset YOUTUBE через костыль"
164 |
165 | # Установка прав на выполнение скриптов
166 | chmod_set >>"$LOG" 2>&1 &
167 | animation $! "Установка прав на выполнение скриптов"
168 |
169 | # Отключение ipv6
170 | disable_ipv6 >>"$LOG" 2>&1 &
171 | animation $! "Отключение ipv6"
172 |
173 | # Завершение
174 | echo ""
175 | complete_info
176 | rm -- "$0"
177 |
178 | # Ждем Enter и ребутимся
179 | read -r
180 | reboot
181 |
--------------------------------------------------------------------------------
/Classic/webui/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/base64"
5 | "encoding/json"
6 | "fmt"
7 | "io"
8 | "io/fs"
9 | "io/ioutil"
10 | "net"
11 | "net/http"
12 | "os"
13 | "os/exec"
14 | "strings"
15 | "sync"
16 | "time"
17 | "embed"
18 | )
19 |
20 | const (
21 | aghServicePath = "/opt/etc/init.d/S99adguardhome"
22 | filePath = "/opt/etc/AdGuardHome/domain.conf"
23 | loginFile = "/opt/etc/HydraRoute/login.scrt"
24 | port = 2000
25 | )
26 |
27 | //go:embed public/*
28 | var embeddedFiles embed.FS
29 |
30 | var (
31 | br0IP = getBr0IP()
32 | serviceLock sync.Mutex
33 | )
34 |
35 | func main() {
36 | if br0IP == "" {
37 | os.Exit(1)
38 | }
39 |
40 | ensureAdGuardRunning()
41 |
42 | mux := http.NewServeMux()
43 |
44 | mux.HandleFunc("/login", loggingMiddleware(loginHandler))
45 | mux.HandleFunc("/logout", loggingMiddleware(logoutHandler))
46 | mux.HandleFunc("/change-password", loggingMiddleware(authMiddleware(changePasswordHandler)))
47 | mux.HandleFunc("/", loggingMiddleware(authMiddleware(indexHandler)))
48 | mux.HandleFunc("/load-services", loggingMiddleware(authMiddleware(loadServicesHandler)))
49 | mux.HandleFunc("/config", loggingMiddleware(authMiddleware(configHandler)))
50 | mux.HandleFunc("/save", loggingMiddleware(authMiddleware(saveHandler)))
51 | mux.HandleFunc("/interfaces", loggingMiddleware(authMiddleware(interfacesHandler)))
52 | mux.HandleFunc("/br0ip", loggingMiddleware(authMiddleware(br0IPHandler)))
53 | mux.HandleFunc("/agh-status", loggingMiddleware(authMiddleware(aghStatusHandler)))
54 | mux.HandleFunc("/agh-restart", loggingMiddleware(authMiddleware(aghRestartHandler)))
55 | mux.HandleFunc("/proxy-fetch", loggingMiddleware(proxyFetchHandler))
56 |
57 | contentFS, err := fs.Sub(embeddedFiles, "public")
58 | if err != nil {
59 | os.Exit(1)
60 | }
61 | mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(contentFS))))
62 |
63 | fmt.Printf("Сервер запущен на http://%s:%d\n", br0IP, port)
64 | http.ListenAndServe(fmt.Sprintf("%s:%d", br0IP, port), mux)
65 | }
66 |
67 | func loggingMiddleware(next http.HandlerFunc) http.HandlerFunc {
68 | return func(w http.ResponseWriter, r *http.Request) {
69 | next(w, r)
70 | }
71 | }
72 |
73 | func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
74 | return func(w http.ResponseWriter, r *http.Request) {
75 | if r.URL.Path == "/login" || isAuthenticated(r) {
76 | next(w, r)
77 | } else {
78 | http.Redirect(w, r, "/login", http.StatusFound)
79 | }
80 | }
81 | }
82 |
83 | func setAuth(w http.ResponseWriter) {
84 | http.SetCookie(w, &http.Cookie{
85 | Name: "authenticated",
86 | Value: "1",
87 | Path: "/",
88 | MaxAge: 86400 * 7,
89 | HttpOnly: true,
90 | })
91 | }
92 |
93 | func clearAuth(w http.ResponseWriter) {
94 | http.SetCookie(w, &http.Cookie{
95 | Name: "authenticated",
96 | Value: "",
97 | Path: "/",
98 | MaxAge: -1,
99 | HttpOnly: true,
100 | })
101 | }
102 |
103 | func isAuthenticated(r *http.Request) bool {
104 | cookie, err := r.Cookie("authenticated")
105 | return err == nil && cookie.Value == "1"
106 | }
107 |
108 | func loginHandler(w http.ResponseWriter, r *http.Request) {
109 | if r.Method == http.MethodGet {
110 | data, err := embeddedFiles.ReadFile("public/login.html")
111 | if err != nil {
112 | http.Error(w, "Login page not found", http.StatusInternalServerError)
113 | return
114 | }
115 | w.Write(data)
116 | return
117 | }
118 |
119 | if err := r.ParseForm(); err != nil {
120 | http.Error(w, "Invalid form data", http.StatusBadRequest)
121 | return
122 | }
123 |
124 | passwordKey := r.FormValue("password_key")
125 | if passwordKey == "" {
126 | http.Error(w, "Password is required", http.StatusBadRequest)
127 | return
128 | }
129 |
130 | encryptedPassword, err := ioutil.ReadFile(loginFile)
131 | if err != nil {
132 | http.Error(w, "Error reading password file", http.StatusInternalServerError)
133 | return
134 | }
135 |
136 | decodedPassword, err := decodeBase64(string(encryptedPassword))
137 | if err != nil {
138 | http.Error(w, "Error decoding password", http.StatusInternalServerError)
139 | return
140 | }
141 |
142 | if passwordKey != decodedPassword {
143 | http.Error(w, "Invalid password", http.StatusUnauthorized)
144 | return
145 | }
146 |
147 | setAuth(w)
148 | http.Redirect(w, r, "/", http.StatusFound)
149 | }
150 |
151 | func logoutHandler(w http.ResponseWriter, r *http.Request) {
152 | clearAuth(w)
153 | http.Redirect(w, r, "/login", http.StatusFound)
154 | }
155 |
156 | func changePasswordHandler(w http.ResponseWriter, r *http.Request) {
157 | var data struct {
158 | CurrentPassword string `json:"currentPassword"`
159 | NewPassword string `json:"newPassword"`
160 | }
161 |
162 | if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
163 | http.Error(w, "Invalid request payload", http.StatusBadRequest)
164 | return
165 | }
166 |
167 | encryptedPassword, err := ioutil.ReadFile(loginFile)
168 | if err != nil {
169 | http.Error(w, "Error reading password file", http.StatusInternalServerError)
170 | return
171 | }
172 |
173 | decodedPassword, err := decodeBase64(string(encryptedPassword))
174 | if err != nil {
175 | http.Error(w, "Error decoding password", http.StatusInternalServerError)
176 | return
177 | }
178 |
179 | if data.CurrentPassword != decodedPassword {
180 | http.Error(w, "Invalid current password", http.StatusUnauthorized)
181 | return
182 | }
183 |
184 | encodedNewPassword := base64.StdEncoding.EncodeToString([]byte(data.NewPassword))
185 | if err := ioutil.WriteFile(loginFile, []byte(encodedNewPassword), 0644); err != nil {
186 | http.Error(w, "Error writing new password to file", http.StatusInternalServerError)
187 | return
188 | }
189 |
190 | w.WriteHeader(http.StatusOK)
191 | json.NewEncoder(w).Encode(map[string]bool{"success": true})
192 | }
193 |
194 | func indexHandler(w http.ResponseWriter, r *http.Request) {
195 | data, err := embeddedFiles.ReadFile("public/index.html")
196 | if err != nil {
197 | http.Error(w, "Index page not found", http.StatusInternalServerError)
198 | return
199 | }
200 | w.Write(data)
201 | }
202 |
203 | func loadServicesHandler(w http.ResponseWriter, r *http.Request) {
204 | url := "https://github.com/Ground-Zerro/DomainMapper/raw/refs/heads/main/platformdb"
205 | var resp *http.Response
206 | var err error
207 |
208 | client := http.Client{
209 | Timeout: 3 * time.Second,
210 | }
211 |
212 | for i := 0; i < 3; i++ {
213 | resp, err = client.Get(url)
214 | if err == nil && resp.StatusCode == http.StatusOK {
215 | break
216 | }
217 | if resp != nil {
218 | resp.Body.Close()
219 | }
220 | }
221 |
222 | if err != nil || resp == nil || resp.StatusCode != http.StatusOK {
223 | http.Error(w, "Не удалось получить список сервисов с GitHub", http.StatusGatewayTimeout)
224 | return
225 | }
226 | defer resp.Body.Close()
227 |
228 | body, err := ioutil.ReadAll(resp.Body)
229 | if err != nil {
230 | http.Error(w, "Ошибка чтения данных", http.StatusInternalServerError)
231 | return
232 | }
233 |
234 | lines := strings.Split(string(body), "\n")
235 | var services []map[string]string
236 | for _, line := range lines {
237 | line = strings.TrimSpace(line)
238 | if line == "" {
239 | continue
240 | }
241 | parts := strings.SplitN(line, ": ", 2)
242 | if len(parts) == 2 {
243 | name := strings.TrimSpace(parts[0])
244 | url := strings.TrimSpace(parts[1])
245 | services = append(services, map[string]string{"name": name, "url": url})
246 | }
247 | }
248 |
249 | w.Header().Set("Content-Type", "application/json")
250 | json.NewEncoder(w).Encode(services)
251 | }
252 |
253 | func getBr0IP() string {
254 | ifaces, err := net.Interfaces()
255 | if err != nil {
256 | return ""
257 | }
258 | for _, iface := range ifaces {
259 | if iface.Name == "br0" {
260 | addrs, err := iface.Addrs()
261 | if err != nil {
262 | return ""
263 | }
264 | for _, addr := range addrs {
265 | if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() && ipnet.IP.To4() != nil {
266 | return ipnet.IP.String()
267 | }
268 | }
269 | }
270 | }
271 | return ""
272 | }
273 |
274 | func ensureAdGuardRunning() {
275 | cmd := exec.Command(aghServicePath, "status")
276 | output, err := cmd.CombinedOutput()
277 | if err != nil || !strings.Contains(string(output), "alive") {
278 | // fmt.Println("AdGuardHome не запущен. Попытка запуска...")
279 | if startErr := exec.Command(aghServicePath, "start").Run(); startErr != nil {
280 | // fmt.Printf("Не удалось запустить AdGuardHome: %v\n", startErr)
281 | } else {
282 | // fmt.Println("AdGuardHome успешно запущен.")
283 | }
284 | } else {
285 | // fmt.Println("AdGuardHome уже запущен.")
286 | }
287 | }
288 |
289 | func decodeBase64(encoded string) (string, error) {
290 | decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(encoded))
291 | if err != nil {
292 | return "", err
293 | }
294 | return string(decoded), nil
295 | }
296 |
297 | func br0IPHandler(w http.ResponseWriter, r *http.Request) {
298 | json.NewEncoder(w).Encode(map[string]string{"ip": br0IP})
299 | }
300 |
301 | func aghStatusHandler(w http.ResponseWriter, r *http.Request) {
302 | cmd := exec.Command("/opt/etc/init.d/S99adguardhome", "status")
303 | output, err := cmd.CombinedOutput()
304 | if err != nil || !strings.Contains(string(output), "alive") {
305 | http.Error(w, "Остановлен", http.StatusInternalServerError)
306 | return
307 | }
308 | w.Write([]byte("Запущен и работает"))
309 | }
310 |
311 | func aghRestartHandler(w http.ResponseWriter, r *http.Request) {
312 | exec.Command(aghServicePath, "stop").Run()
313 | for _, ipset := range []string{"hr1", "hr2", "hr3"} {
314 | exec.Command("ipset", "flush", ipset).Run()
315 | }
316 | err := exec.Command(aghServicePath, "start").Run()
317 | if err != nil {
318 | http.Error(w, "Ошибка перезапуска AdGuardHome", http.StatusInternalServerError)
319 | return
320 | }
321 | w.Write([]byte("AdGuardHome перезапущен"))
322 | }
323 |
324 | func configHandler(w http.ResponseWriter, r *http.Request) {
325 | data := parseConfig()
326 | json.NewEncoder(w).Encode(data)
327 | }
328 |
329 | func parseConfig() map[string][]map[string]interface{} {
330 | content, err := ioutil.ReadFile(filePath)
331 | if err != nil {
332 | return nil
333 | }
334 | lines := strings.Split(string(content), "\n")
335 | result := map[string][]map[string]interface{}{
336 | "hr1": {}, "hr2": {}, "hr3": {},
337 | }
338 | desc := ""
339 |
340 | for _, line := range lines {
341 | if strings.HasPrefix(line, "##") {
342 | desc = strings.TrimSpace(line[2:])
343 | continue
344 | }
345 | active := true
346 | if strings.HasPrefix(line, "#") {
347 | active = false
348 | line = line[1:]
349 | }
350 | if idx := strings.LastIndex(line, "/"); idx != -1 {
351 | domains := strings.TrimSpace(line[:idx])
352 | ipset := line[idx+1:]
353 | if _, ok := result[ipset]; ok {
354 | result[ipset] = append(result[ipset], map[string]interface{}{
355 | "domains": domains,
356 | "active": active,
357 | "description": desc,
358 | })
359 | desc = ""
360 | }
361 | }
362 | }
363 | return result
364 | }
365 |
366 | func saveHandler(w http.ResponseWriter, r *http.Request) {
367 | var data map[string][]map[string]interface{}
368 | if err := json.NewDecoder(r.Body).Decode(&data); err != nil {
369 | http.Error(w, "Ошибка декодирования", http.StatusBadRequest)
370 | return
371 | }
372 |
373 | var lines []string
374 | for ipset, entries := range data {
375 | for _, e := range entries {
376 | if d, ok := e["description"].(string); ok && strings.TrimSpace(d) != "" {
377 | lines = append(lines, "##"+d)
378 | }
379 | line := e["domains"].(string)
380 | if !e["active"].(bool) {
381 | line = "#" + line
382 | }
383 | lines = append(lines, line+"/"+ipset)
384 | }
385 | }
386 |
387 | if err := ioutil.WriteFile(filePath, []byte(strings.Join(lines, "\n")), 0644); err != nil {
388 | http.Error(w, "Ошибка сохранения", http.StatusInternalServerError)
389 | return
390 | }
391 |
392 | exec.Command(aghServicePath, "stop").Run()
393 | for _, ipset := range []string{"hr1", "hr2", "hr3"} {
394 | exec.Command("ipset", "flush", ipset).Run()
395 | }
396 | exec.Command(aghServicePath, "start").Run()
397 |
398 | json.NewEncoder(w).Encode(map[string]bool{"success": true})
399 | }
400 |
401 | func interfacesHandler(w http.ResponseWriter, r *http.Request) {
402 | policyNames := []string{"HydraRoute1st", "HydraRoute2nd", "HydraRoute3rd"}
403 | polOut, err := exec.Command("curl", "-kfsS", "localhost:79/rci/show/ip/policy/").Output()
404 | if err != nil {
405 | http.Error(w, "Ошибка curl", http.StatusInternalServerError)
406 | return
407 | }
408 | var policies map[string]interface{}
409 | json.Unmarshal(polOut, &policies)
410 |
411 | ifOut, err := exec.Command("curl", "-kfsS", "localhost:79/rci/show/interface/").Output()
412 | if err != nil {
413 | http.Error(w, "Ошибка curl интерфейса", http.StatusInternalServerError)
414 | return
415 | }
416 | var ifaceMap map[string]interface{}
417 | json.Unmarshal(ifOut, &ifaceMap)
418 |
419 | var ifaceList []map[string]interface{}
420 | for _, v := range ifaceMap {
421 | ifaceList = append(ifaceList, v.(map[string]interface{}))
422 | }
423 |
424 | getDesc := func(id string) string {
425 | for _, iface := range ifaceList {
426 | if iface["id"] == id {
427 | return iface["description"].(string)
428 | }
429 | }
430 | return "Null"
431 | }
432 |
433 | var result []string
434 | for _, pname := range policyNames {
435 | entry := policies[pname].(map[string]interface{})
436 | routes := entry["route4"].(map[string]interface{})["route"].([]interface{})
437 | ifaceID := "Null"
438 | for _, r := range routes {
439 | route := r.(map[string]interface{})
440 | if route["destination"] == "0.0.0.0/0" {
441 | ifaceID = route["interface"].(string)
442 | break
443 | }
444 | }
445 | if ifaceID == "Null" {
446 | result = append(result, "Null")
447 | } else {
448 | result = append(result, getDesc(ifaceID))
449 | }
450 | }
451 |
452 | json.NewEncoder(w).Encode(result)
453 | }
454 |
455 | func proxyFetchHandler(w http.ResponseWriter, r *http.Request) {
456 | url := r.URL.Query().Get("url")
457 | if url == "" {
458 | http.Error(w, "Missing URL", http.StatusBadRequest)
459 | return
460 | }
461 |
462 | resp, err := http.Get(url)
463 | if err != nil || resp.StatusCode != 200 {
464 | http.Error(w, "Failed to fetch resource", http.StatusBadGateway)
465 | return
466 | }
467 | defer resp.Body.Close()
468 |
469 | w.Header().Set("Content-Type", "text/plain")
470 | io.Copy(w, resp.Body)
471 | }
--------------------------------------------------------------------------------
/Classic/webui/public/assets/fonts/Models-Logo.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ground-Zerro/HydraRoute/3b58d853c0d1d564a6ebc54d6b9069fd34754d24/Classic/webui/public/assets/fonts/Models-Logo.woff2
--------------------------------------------------------------------------------
/Classic/webui/public/assets/fonts/Roboto-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ground-Zerro/HydraRoute/3b58d853c0d1d564a6ebc54d6b9069fd34754d24/Classic/webui/public/assets/fonts/Roboto-Bold.woff2
--------------------------------------------------------------------------------
/Classic/webui/public/assets/fonts/Roboto-Italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ground-Zerro/HydraRoute/3b58d853c0d1d564a6ebc54d6b9069fd34754d24/Classic/webui/public/assets/fonts/Roboto-Italic.woff2
--------------------------------------------------------------------------------
/Classic/webui/public/assets/fonts/Roboto-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ground-Zerro/HydraRoute/3b58d853c0d1d564a6ebc54d6b9069fd34754d24/Classic/webui/public/assets/fonts/Roboto-Regular.woff2
--------------------------------------------------------------------------------
/Classic/webui/public/assets/sprite/adguard.svg:
--------------------------------------------------------------------------------
1 |
2 |
9 |
--------------------------------------------------------------------------------
/Classic/webui/public/assets/sprite/check-mark-small.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Classic/webui/public/assets/sprite/dashboard.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Classic/webui/public/assets/sprite/delete.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Classic/webui/public/assets/sprite/donate.svg:
--------------------------------------------------------------------------------
1 |
2 |
235 |
--------------------------------------------------------------------------------
/Classic/webui/public/assets/sprite/github.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
60 |
--------------------------------------------------------------------------------
/Classic/webui/public/assets/sprite/hide-password.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Classic/webui/public/assets/sprite/info.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Classic/webui/public/assets/sprite/logout.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
--------------------------------------------------------------------------------
/Classic/webui/public/assets/sprite/settings.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/Classic/webui/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Hydra Route
7 |
8 |
9 |
10 |
11 |
12 | Hydra Route
13 | for Keenetic
14 |
15 |
16 |
35 |
36 |
37 |
38 |
44 |
45 |
Добавлять устройства в политику – не нужно.
46 |
47 | - Правила переадресации доменов применяются ко всему, что подключено к роутеру.
48 | - При помещении устройства в политику ВЕСЬ его трафик будет перенаправляться через указанное в политике подключение.
49 |
50 |
Создавать отдельное поле для каждого домена – избыточно.
51 |
52 | - Соберите домены в группу и дайте ей краткое описание.
53 | - Домены внутри группы разделяются запятой. Пробелы добавляются для удобства.
54 |
55 |
Неиспользуемые домены можно временно отключить.
56 |
57 | - Чек бокс напротив доменных имен отвечает за их включение и выключение.
58 | - Вместо удаления можно отключить их на время и включить когда они понадобятся вновь.
59 |
60 |
Данные об IP-адресе на специальных сервисах могут какое-то время оставаться прежними после внесения изменений в HR.
61 |
62 | - Помните, что DNS кэш есть и у браузера и у операционной системы.
63 | - Если сомневаетесь в результате - выполните tracert, чтобы убедиться по какому маршруту трафик идет к нужному домену.
64 |
65 |
Домены третьего уровня и выше подхватываются автоматически.
66 |
67 | - Указав yandex.ru будут перенаправляться все домены, заканивающиеся на yandex.ru, в т.ч. mail.yandex.ru, disk.yandex.ru и т.д
68 |
69 |
70 |
71 |
72 |
73 |
74 |
78 |
82 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
109 |
110 |
AdGuard Home не отображает статистику.
111 |
112 | - По умолчанию статистика отключена т.к. журалы занимают место, включите ее в настрйоках:
113 | - Веб-конфигуратор -> Настройки -> Основные -> "Включить журнал", "Включить статистику".
114 |
115 |
Если нужны домены .local - укажите их в AdGuard Home:
116 |
117 | - Веб-конфигуратор -> Фильтры -> Перезапись DNS-запросов.
118 |
119 |
Как разблокировать сайт в AdGuard Home?
120 |
121 | - Веб-конфигуратор -> Фильтры -> Пользовательские правила фильтрации -> Проверить фильтрацию ->
122 | ввести имя хоста -> нажать "Првоерить" -> нажать "Разблокировать".
123 |
124 |
125 |
126 |
130 |
131 |
Перейти в веб-конфигуратор
132 |
Login: admin
133 |
Password: keenetic
134 |
135 |
136 |
137 |
143 |
144 |
145 |
Сменить пароль доступа к HydraRoute
146 |
161 |
162 |
163 |
164 |
165 |
166 |
172 |
173 |
Этот функционал в разработке...
174 |
Пожалуйста, подождите, пока завершится работа.
175 |
176 |
177 |
178 |
193 |
194 |
195 |
196 |
197 |
198 |
199 |
200 |
201 |
202 |
203 |
--------------------------------------------------------------------------------
/Classic/webui/public/login.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Авторизация - HydraRoute
7 |
167 |
168 |
169 |
170 |
HydraRoute
171 |
200 |
201 |
202 |
249 |
250 |
--------------------------------------------------------------------------------
/Classic/webui/public/scrypt.js:
--------------------------------------------------------------------------------
1 | function openModal(title, message) {
2 | const modal = document.getElementById('modal');
3 | const modalTitle = document.getElementById('modal-title');
4 | const modalMessage = document.getElementById('modal-message');
5 | const modalButton = document.querySelector('.modal-content button');
6 |
7 | modalTitle.textContent = title;
8 | modalMessage.innerHTML = message;
9 |
10 | if (message.includes('Войдите снова')) {
11 | modalButton.textContent = "Войти";
12 | modalButton.onclick = () => window.location.href = "/login";
13 | } else {
14 | modalButton.textContent = "Закрыть";
15 | modalButton.onclick = closeModal;
16 | }
17 |
18 | modal.style.display = 'block';
19 | }
20 |
21 | function closeModal() {
22 | const modal = document.getElementById('modal');
23 | modal.style.display = 'none';
24 | }
25 |
26 | function hideAllContent() {
27 | document.getElementById('dashboard-content').style.display = 'none';
28 | document.getElementById('adguard-content').style.display = 'none';
29 | document.getElementById('info-content').style.display = 'none';
30 | document.getElementById('settings-content').style.display = 'none';
31 | }
32 |
33 | document.querySelector('.sidebar .icon:nth-child(1)').addEventListener('click', () => showContent('dashboard-content'));
34 | document.querySelector('.sidebar .icon:nth-child(2)').addEventListener('click', () => showContent('adguard-content'));
35 | document.querySelector('.sidebar .icon:nth-child(3)').addEventListener('click', () => showContent('info-content'));
36 | document.querySelector('.sidebar .icon:nth-child(4)').addEventListener('click', () => showContent('settings-content'));
37 |
38 | showContent('dashboard-content');
39 |
40 | function showContent(contentId) {
41 | hideAllContent();
42 | document.getElementById(contentId).style.display = 'block';
43 | document.querySelectorAll('.sidebar .icon').forEach(icon => {
44 | icon.classList.remove('active');
45 | });
46 | const iconIndex = Array.from(document.querySelectorAll('.sidebar .icon')).findIndex(icon => {
47 | return icon.querySelector('img').alt.toLowerCase().includes(contentId.replace('-content', ''));
48 | });
49 | if (iconIndex !== -1) {
50 | document.querySelectorAll('.sidebar .icon')[iconIndex].classList.add('active');
51 | }
52 | }
53 |
54 | document.addEventListener('DOMContentLoaded', function () {
55 | document.querySelectorAll('.description-header').forEach(header => {
56 | const content = header.nextElementSibling;
57 | const toggleButton = header.querySelector('.description-toggle');
58 | header.addEventListener('click', function () {
59 | const isExpanded = content.classList.toggle('expanded');
60 | toggleButton.classList.toggle('rotated', isExpanded);
61 | });
62 | });
63 | });
64 |
65 | let allData = { hr1: [], hr2: [], hr3: [] };
66 |
67 | document.addEventListener("DOMContentLoaded", async () => {
68 | let activePolicy = "hr1";
69 | const policyNames = { hr1: "HydraRoute1st", hr2: "HydraRoute2nd", hr3: "HydraRoute3rd" };
70 | const domainsContainer = document.getElementById("dashboard-domains-container");
71 | const addFieldButton = document.getElementById("dashboard-add-field");
72 | const saveButton = document.getElementById("dashboard-save");
73 | const resetButton = document.getElementById("dashboard-reset");
74 | const githubButton = document.getElementById("load-from-github");
75 | githubButton.addEventListener("click", async () => {
76 | openModal("Загрузка сервисов", `
77 | Загрузка...
78 |
81 |
82 | `);
83 |
84 | const services = await fetch("/load-services").then(r => r.json()).catch(() => []);
85 | const list = document.getElementById("service-list");
86 |
87 | if (services.length === 0) {
88 | list.innerHTML = "Не удалось загрузить список сервисов";
89 | return;
90 | }
91 |
92 | services.forEach(service => {
93 | const li = document.createElement("li");
94 | li.innerHTML = `
95 |
100 | `;
101 | list.appendChild(li);
102 | });
103 |
104 | document.getElementById("confirm-service-load").addEventListener("click", loadFromGithub);
105 | });
106 |
107 | const urlButton = document.getElementById("load-from-url");
108 | urlButton.addEventListener("click", () => {
109 | openModal("Загрузка по ссылке", `
110 | Укажите ссылку на файл со списком доменов:
111 |
112 |
113 | `);
114 |
115 | document.getElementById("confirm-url-load").addEventListener("click", async () => {
116 | const input = document.getElementById("custom-url").value.trim();
117 | if (!input.startsWith("http")) {
118 | openModal("Ошибка", "Неверная ссылка.");
119 | return;
120 | }
121 |
122 | try {
123 | const domains = await loadDomainsFromGithub([input]); // использует уже существующую функцию
124 | if (domains.length === 0) {
125 | openModal("Информация", "Новых доменов не найдено.");
126 | return;
127 | }
128 | addDomainsToField(domains);
129 | closeModal();
130 | } catch (error) {
131 | console.error('Ошибка загрузки по ссылке:', error);
132 | openModal('Ошибка', 'Не удалось загрузить домены по указанной ссылке.');
133 | }
134 | });
135 | });
136 |
137 | const policyButtons = document.querySelectorAll(".dashboard-policy-btn");
138 | policyButtons[0].classList.add("active");
139 | async function getInterfaces() {
140 | try {
141 | const response = await fetch('/interfaces');
142 | return await response.json();
143 | } catch (error) {
144 | return ["Null", "Null", "Null"];
145 | }
146 | }
147 |
148 | async function updatePolicyButtons() {
149 | const interfaces = await getInterfaces();
150 | policyButtons.forEach((button, index) => {
151 | const policyName = button.getAttribute('data-ipset');
152 | let interfaceName = interfaces[index] || 'Null';
153 |
154 | if (interfaceName === 'Null') {
155 | interfaceName = 'Нет активного подключения';
156 | }
157 |
158 | button.innerHTML = `
159 | ${policyNames[policyName]}
160 | ${interfaceName}
161 | `;
162 | });
163 | }
164 |
165 | updatePolicyButtons();
166 |
167 | function sanitizeInput(input, forUi = false) {
168 | input = input.trim();
169 | if (input.length === 0) return '';
170 | input = input.replace(/[\r\n:; ]+/g, ',');
171 | input = input.replace(/,+/g, ',');
172 | input = input.replace(/[^a-zA-Z0-9#.,-]/g, '');
173 | input = input.split(',').filter(domain => /\w+\.\w{2,}$/.test(domain));
174 | return input.length > 0 ? input.join(forUi ? ', ' : ',') : '';
175 | }
176 |
177 | function saveCurrentPolicy() {
178 | allData[activePolicy] = [...domainsContainer.children]
179 | .map(div => {
180 | const sanitizedDomains = sanitizeInput(div.querySelector("textarea").value);
181 | const description = div.querySelector(".description").value.trim();
182 | return sanitizedDomains ? { domains: sanitizedDomains, active: div.querySelector("input[type='checkbox']").checked, description } : null;
183 | })
184 | .filter(entry => entry !== null);
185 | }
186 |
187 | function loadPolicy(policy) {
188 | domainsContainer.innerHTML = "";
189 | allData[policy].forEach(item => addDomainField(item.domains, item.active, item.description || ''));
190 | }
191 |
192 | function addDomainField(value = "", active = true, description = "") {
193 | const div = document.createElement("div");
194 | div.classList.add("domain-entry");
195 | div.innerHTML = `
196 |
197 |
203 |
204 |
205 |
206 |
207 |
208 |
209 |
213 |
214 |
215 |
216 | `;
217 | domainsContainer.appendChild(div);
218 | div.querySelector(".remove").addEventListener("click", () => div.remove());
219 | }
220 |
221 | function addDomainsToField(domains, source = 'Загружено по ссылке') {
222 | const grouped = domains.length > 0 ? domains.join(',') : '';
223 | if (grouped) {
224 | addDomainField(grouped, true, source);
225 | }
226 | }
227 |
228 | function resetCheckboxes() {
229 | const checkboxes = document.querySelectorAll('input[name="services[]"]');
230 | checkboxes.forEach(checkbox => checkbox.checked = false);
231 | }
232 |
233 | async function loadFromGithub() {
234 | const selectedServices = Array.from(document.querySelectorAll('input[name="services[]"]:checked'))
235 | .map(input => input.value);
236 |
237 | if (selectedServices.length === 0) {
238 | openModal('Ошибка', 'Выберите хотя бы один сервис.');
239 | return;
240 | }
241 |
242 | try {
243 | const domains = await loadDomainsFromGithub(selectedServices);
244 | if (domains.length === 0) {
245 | openModal('Информация', 'Новых доменов не найдено.');
246 | return;
247 | }
248 | addDomainsToField(domains, 'Загружено с GitHub');
249 | resetCheckboxes();
250 | closeModal();
251 | } catch (error) {
252 | console.error('Ошибка загрузки:', error);
253 | openModal('Ошибка', 'Ошибка при загрузке доменов.');
254 | }
255 | }
256 |
257 | function loadAllData() {
258 | fetch("/config")
259 | .then(response => {
260 | if (!response.ok) {
261 | if (response.status === 401) {
262 | window.location.href = "/login";
263 | return;
264 | }
265 | throw new Error('Ошибка загрузки данных');
266 | }
267 | return response.json();
268 | })
269 | .then(data => {
270 | if (!data) return;
271 | allData = data;
272 | loadPolicy(activePolicy);
273 | })
274 | .catch(error => {
275 | console.error("Ошибка загрузки данных:", error);
276 | openModal('Ошибка', 'Сессия истекла.
Войдите снова.');
277 | });
278 | }
279 |
280 | function getSecondLevelDomain(domain) {
281 | const parts = domain.split('.');
282 | return parts.length > 2 ? parts.slice(-2).join('.') : domain;
283 | }
284 |
285 | function validateData() {
286 | const domainMap = new Map();
287 | let domainErrors = new Map();
288 | const activePolicies = new Set();
289 |
290 | Object.keys(allData).forEach(policy => {
291 | const policyDomains = new Set();
292 | allData[policy].forEach(entry => {
293 | if (!entry.active) return;
294 |
295 | const domains = entry.domains.split(',').map(d => d.trim());
296 |
297 | domains.forEach(domain => {
298 | const secondLevel = getSecondLevelDomain(domain);
299 |
300 | if (policyDomains.has(secondLevel)) {
301 | if (!domainErrors.has(secondLevel)) {
302 | domainErrors.set(secondLevel, new Set());
303 | }
304 | domainErrors.get(secondLevel).add(policy);
305 | activePolicies.add(policy);
306 | } else {
307 | policyDomains.add(secondLevel);
308 | }
309 |
310 | if (domainMap.has(secondLevel) && domainMap.get(secondLevel) !== policy) {
311 | if (!domainErrors.has(secondLevel)) {
312 | domainErrors.set(secondLevel, new Set());
313 | }
314 | domainErrors.get(secondLevel).add(policy);
315 | domainErrors.get(secondLevel).add(domainMap.get(secondLevel));
316 | activePolicies.add(policy);
317 | activePolicies.add(domainMap.get(secondLevel));
318 | } else {
319 | domainMap.set(secondLevel, policy);
320 | }
321 | });
322 | });
323 | });
324 |
325 | if (domainErrors.size > 0) {
326 | const policies = Array.from(activePolicies);
327 | let tableHTML = '';
328 |
329 | tableHTML += '';
330 | policies.forEach(policy => {
331 | tableHTML += `${policyNames[policy]} | `;
332 | });
333 | tableHTML += '
';
334 |
335 | domainErrors.forEach((policiesSet, domain) => {
336 | tableHTML += '';
337 | policies.forEach(policy => {
338 | if (policiesSet.has(policy)) {
339 | tableHTML += `${domain} | `;
340 | } else {
341 | tableHTML += ' | ';
342 | }
343 | });
344 | tableHTML += '
';
345 | });
346 |
347 | tableHTML += '
';
348 | openModal("Пересекающиеся или одинаковые домены", tableHTML);
349 | return false;
350 | }
351 |
352 | return true;
353 | }
354 |
355 | addFieldButton.addEventListener("click", () => addDomainField());
356 |
357 | saveButton.addEventListener("click", () => {
358 | saveCurrentPolicy();
359 |
360 | if (!validateData()) return;
361 |
362 | fetch("/save", {
363 | method: "POST",
364 | headers: { "Content-Type": "application/json" },
365 | body: JSON.stringify(allData)
366 | })
367 | .then(response => response.json())
368 | .then(result => {
369 | if (result.success) {
370 | const loaderHTML = `
371 |
372 |
Запуск DNS сервера, подождите...
373 |
376 |
377 | `;
378 | openModal("Сохранено", loaderHTML);
379 |
380 | let progress = 0;
381 | const interval = setInterval(() => {
382 | progress += 1;
383 | document.getElementById('dns-progress').style.width = progress + '%';
384 | if (progress >= 100) {
385 | clearInterval(interval);
386 | document.getElementById('modal-message').innerHTML = 'DNS сервер успешно перезапущен.
';
387 | }
388 | }, 150); // 150 мс * 100 шагов ≈ 15 секунд
389 |
390 | loadAllData();
391 | } else {
392 | openModal("Ошибка", "Что-то пошло не так: " + (result.error || "Неизвестная ошибка"));
393 | }
394 | })
395 | .catch(error => {
396 | console.error("Ошибка сохранения:", error);
397 | openModal("Ошибка", "Ошибка сохранения: " + error.message);
398 | });
399 | });
400 |
401 | resetButton.addEventListener("click", loadAllData);
402 |
403 | policyButtons.forEach(button => {
404 | button.addEventListener("click", () => {
405 | policyButtons.forEach(btn => btn.classList.remove("active"));
406 | button.classList.add("active");
407 | saveCurrentPolicy();
408 | activePolicy = button.getAttribute("data-ipset");
409 | loadPolicy(activePolicy);
410 | });
411 | });
412 |
413 | loadAllData();
414 | });
415 |
416 | fetch('/br0ip')
417 | .then(response => response.json())
418 | .then(data => {
419 | if (data.ip) {
420 | document.getElementById('adguard-link').href = `http://${data.ip}:3000`;
421 | } else {
422 | console.error('Не удалось получить IP-адрес br0');
423 | openModal('Ошибка', 'Не удалось получить IP-адрес br0');
424 | }
425 | })
426 | .catch(error => {
427 | console.error('Ошибка получения IP:', error);
428 | openModal('Ошибка', 'Ошибка получения IP: ' + error.message);
429 | });
430 |
431 | function updateStatus() {
432 | fetch('/agh-status')
433 | .then(response => response.text())
434 | .then(data => {
435 | document.getElementById('adguard-status').textContent = `Статус: ${data}`;
436 | })
437 | .catch(() => {
438 | document.getElementById('adguard-status').textContent = 'Ошибка при получении статуса';
439 | openModal('Ошибка', 'Ошибка при получении статуса');
440 | });
441 | }
442 |
443 | document.getElementById('adguard-restart-button').addEventListener('click', function(event) {
444 | event.preventDefault();
445 | fetch('/agh-restart', {
446 | method: 'POST',
447 | })
448 | .then(response => response.text())
449 | .then(data => {
450 | openModal('Успех', data);
451 | updateStatus();
452 | })
453 | .catch(error => {
454 | openModal('Ошибка', error.message);
455 | });
456 | });
457 |
458 | function getRootDomain(domain) {
459 | const clean = domain.trim().replace(/^\.+|\.+$/g, '');
460 | const parts = clean.split('.').filter(Boolean);
461 |
462 | if (parts.length <= 2) {
463 | return clean;
464 | }
465 | return parts.slice(-2).join('.');
466 | }
467 |
468 | function getExistingRootDomainsFromData() {
469 | const domainsOnPage = new Set();
470 |
471 | Object.values(allData).forEach(entries => {
472 | entries.forEach(entry => {
473 | if (!entry.domains) return;
474 | entry.domains.split(',').forEach(domain => {
475 | const cleaned = domain.trim();
476 | if (cleaned) {
477 | domainsOnPage.add(getRootDomain(cleaned));
478 | }
479 | });
480 | });
481 | });
482 |
483 | return domainsOnPage;
484 | }
485 |
486 | function consolidateDomains(domains) {
487 | const rootMap = {};
488 |
489 | domains.forEach(d => {
490 | const root = getRootDomain(d);
491 | if (!rootMap[root]) rootMap[root] = [];
492 | rootMap[root].push(d);
493 | });
494 |
495 | const consolidated = [];
496 | for (const [root, list] of Object.entries(rootMap)) {
497 | if (list.length > 1) {
498 | consolidated.push(root);
499 | } else {
500 | consolidated.push(list[0]);
501 | }
502 | }
503 | return consolidated;
504 | }
505 |
506 | async function loadDomainsFromGithub(serviceUrls) {
507 | if (!Array.isArray(serviceUrls) || serviceUrls.length === 0) return [];
508 |
509 | const existingRoots = getExistingRootDomainsFromData();
510 | const allDomains = new Set();
511 |
512 | for (const url of serviceUrls) {
513 | try {
514 | const response = await fetch(`/proxy-fetch?url=${encodeURIComponent(url)}`);
515 | const text = await response.text();
516 | const lines = text.replace(/\uFEFF/g, '').split('\n');
517 |
518 | lines.forEach(line => {
519 | const domain = line.trim();
520 | if (domain) {
521 | const root = getRootDomain(domain);
522 | if (!existingRoots.has(root)) {
523 | allDomains.add(domain);
524 | }
525 | }
526 | });
527 | } catch (e) {
528 | console.warn(`Ошибка загрузки ${url}:`, e);
529 | }
530 | }
531 |
532 | return consolidateDomains([...allDomains]);
533 | }
534 |
535 | function logout() {
536 | fetch('/logout', {
537 | method: 'GET',
538 | })
539 | .then(response => {
540 | if (response.redirected) {
541 | window.location.href = response.url; // Перенаправление на страницу входа
542 | }
543 | })
544 | .catch(error => {
545 | console.error('Ошибка при выходе из системы:', error);
546 | openModal('Ошибка', 'Ошибка при выходе из системы');
547 | });
548 | }
549 |
550 | document.getElementById('change-password-form').addEventListener('submit', async (event) => {
551 | event.preventDefault();
552 |
553 | const currentPassword = document.getElementById('current-password').value;
554 | const newPassword = document.getElementById('new-password').value;
555 | const confirmPassword = document.getElementById('confirm-password').value;
556 | const messageElement = document.getElementById('password-change-message');
557 |
558 | if (newPassword !== confirmPassword) {
559 | messageElement.textContent = 'Новый пароль и подтверждение не совпадают.';
560 | return;
561 | }
562 |
563 | try {
564 | const response = await fetch('/change-password', {
565 | method: 'POST',
566 | headers: {
567 | 'Content-Type': 'application/json',
568 | },
569 | body: JSON.stringify({
570 | currentPassword,
571 | newPassword,
572 | }),
573 | });
574 |
575 | const result = await response.json();
576 |
577 | if (result.success) {
578 | messageElement.textContent = 'Пароль успешно изменен.';
579 | } else {
580 | messageElement.textContent = result.error || 'Ошибка при смене пароля.';
581 | }
582 | } catch (error) {
583 | messageElement.textContent = 'Ошибка при отправке запроса.';
584 | }
585 | });
586 |
587 | updateStatus();
--------------------------------------------------------------------------------
/Classic/webui/public/style.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: 'Roboto-Bold';
3 | src: url('assets/fonts/Roboto-Bold.woff2') format('woff2');
4 | font-weight: bold;
5 | font-style: normal;
6 | }
7 |
8 | @font-face {
9 | font-family: 'Roboto-Regular';
10 | src: url('assets/fonts/Roboto-Regular.woff2') format('woff2');
11 | font-weight: normal;
12 | font-style: normal;
13 | }
14 |
15 | @font-face {
16 | font-family: 'Roboto-Italic';
17 | src: url('assets/fonts/Roboto-Italic.woff2') format('woff2');
18 | font-weight: normal;
19 | font-style: normal;
20 | }
21 |
22 | @font-face {
23 | font-family: 'Models-Logo';
24 | src: url('assets/fonts/Models-Logo.woff2') format('woff2');
25 | font-weight: normal;
26 | font-style: normal;
27 | }
28 |
29 | body {
30 | font-family: 'Roboto-Regular', sans-serif;
31 | margin: 0;
32 | padding: 0;
33 | background-color: #ffffff;
34 | display: flex;
35 | flex-direction: column;
36 | min-height: 100vh;
37 | }
38 | .container {
39 | flex: 1;
40 | width: 100%;
41 | min-width: 320px;
42 | background-color: #f0f0f0;
43 | display: flex;
44 | flex-direction: column;
45 | }
46 | header {
47 | text-align: left;
48 | padding: 10px 20px;
49 | background-color: #ffffff;
50 | color: #333;
51 | }
52 | header h1 {
53 | margin: 0;
54 | font-size: 2em;
55 | color: #2396da;
56 | font-family: 'Roboto-Bold', sans-serif;
57 | }
58 | header .subtitle {
59 | font-family: 'Models-Logo';
60 | color: #333;
61 | margin-top: 5px;
62 | }
63 | .content {
64 | display: flex;
65 | flex: 1;
66 | }
67 |
68 | .sidebar {
69 | opacity: 0.5;
70 | width: 72px;
71 | background-color: #ffffff;
72 | display: flex;
73 | flex-direction: column;
74 | margin: 0;
75 | }
76 | .sidebar .icon {
77 | width: 72px;
78 | height: 72px;
79 | display: flex;
80 | align-items: center;
81 | justify-content: center;
82 | transition: background-color 0.3s ease;
83 | }
84 | .sidebar .icon img {
85 | width: 24px;
86 | height: 24px;
87 | filter: brightness(0) saturate(100%) invert(37%) sepia(100%) saturate(506%) hue-rotate(173deg) brightness(100%) contrast(200%);
88 | }
89 | .sidebar .icon:hover {
90 | background-color: #d6d8d9;
91 | }
92 | .sidebar .icon.active {
93 | background-color: #d6d8d9;
94 | }
95 |
96 | .sidebar .icon button {
97 | all: unset; /* Убираем все стандартные стили кнопки */
98 | width: 72px;
99 | height: 72px;
100 | display: flex;
101 | align-items: center;
102 | justify-content: center;
103 | transition: background-color 0.3s ease;
104 | cursor: pointer;
105 | }
106 |
107 | .sidebar .icon button img {
108 | width: 24px;
109 | height: 24px;
110 | filter: brightness(0) saturate(100%) invert(37%) sepia(100%) saturate(506%) hue-rotate(173deg) brightness(100%) contrast(200%);
111 | }
112 |
113 | .sidebar .icon button:hover {
114 | background-color: #d6d8d9;
115 | }
116 |
117 | .main-content {
118 | flex: 1;
119 | background-color: #ffffff;
120 | padding: 30px;
121 | position: relative;
122 | }
123 |
124 | .main-content::before {
125 | content: '';
126 | position: absolute;
127 | top: 0;
128 | left: 0;
129 | right: 0;
130 | height: 20px;
131 | background: linear-gradient(to bottom, #f0f0f0, #ffffff);
132 | }
133 |
134 | footer {
135 | text-align: center;
136 | padding: 20px 0;
137 | background-color: #ffffff;
138 | color: #333;
139 | font-family: 'Roboto-Bold', sans-serif;
140 | }
141 |
142 | footer .button-container {
143 | display: flex;
144 | justify-content: center;
145 | gap: 10px;
146 | margin-top: 10px;
147 | }
148 |
149 | footer .button-container button {
150 | display: flex;
151 | align-items: center;
152 | justify-content: center;
153 | border: none;
154 | border-radius: 5px;
155 | padding: 8px 16px;
156 | cursor: pointer;
157 | font-family: 'Roboto-Bold', sans-serif;
158 | transition: background-color 0.3s ease;
159 | }
160 |
161 | footer .button-container button:hover {
162 | background-color: #d6d8d9;
163 | }
164 |
165 | footer .button-container button img.icon {
166 | width: 30px;
167 | height: 30px;
168 | margin-right: 8px;
169 | }
170 |
171 | .modal {
172 | display: none;
173 | position: fixed;
174 | z-index: 1000;
175 | left: 0;
176 | top: 0;
177 | width: 100%;
178 | height: 100%;
179 | overflow: auto;
180 | background-color: rgba(0, 0, 0, 0.5);
181 | }
182 |
183 | .modal-content {
184 | background-color: #fff;
185 | margin: 15% auto;
186 | padding: 20px;
187 | border: 1px solid #ddd;
188 | border-radius: 8px;
189 | max-width: 500px;
190 | text-align: center;
191 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
192 | }
193 |
194 | .modal-content h2 {
195 | font-size: 20px;
196 | margin-bottom: 15px;
197 | }
198 |
199 | .modal-content p {
200 | font-size: 16px;
201 | margin-bottom: 20px;
202 | }
203 |
204 | .modal-content button {
205 | background-color: #2396da;
206 | color: white;
207 | padding: 10px 20px;
208 | border: none;
209 | border-radius: 4px;
210 | cursor: pointer;
211 | font-size: 16px;
212 | }
213 |
214 | .modal-content button:hover {
215 | background-color: #2396da;
216 | }
217 |
218 | .dashboard-content,
219 | .adguard-content,
220 | .info-content,
221 | .settings-content {
222 | display: none;
223 | }
224 |
225 | .adguard-status-container {
226 | display: flex;
227 | align-items: center;
228 | gap: 10px;
229 | }
230 |
231 | #adguard-status {
232 | margin: 0;
233 | }
234 |
235 | .adguard-restart, .adguard-button {
236 | box-sizing: border-box;
237 | font-family: Roboto, sans-serif;
238 | font-size: 14px;
239 | align-items: center;
240 | column-gap: 8px;
241 | cursor: pointer;
242 | flex-direction: row;
243 | justify-content: center;
244 | line-height: 22px;
245 | min-width: 92px;
246 | padding: 8px 12px;
247 | background-color: rgb(0, 151, 220);
248 | border-radius: 4px;
249 | color: rgb(255, 255, 255);
250 | outline: rgb(255, 255, 255) none 0px;
251 | border: 1px solid rgb(0, 151, 220);
252 | text-decoration: none;
253 | transition: box-shadow 0.3s ease;
254 | }
255 |
256 | .adguard-restart:hover, .adguard-button:hover {
257 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
258 | }
259 |
260 | .description-container {
261 | margin-bottom: 14px;
262 | background-color: #f9f9f9;
263 | border-left: 4px solid #2396da;
264 | border-radius: 5px;
265 | padding: 4px 12px;
266 | font-family: 'Roboto-Regular', sans-serif;
267 | font-size: 14px;
268 | color: #333;
269 | display: inline-block;
270 | }
271 |
272 | .description-header {
273 | display: inline-flex;
274 | align-items: center;
275 | cursor: pointer;
276 | width: auto;
277 | white-space: nowrap;
278 | }
279 |
280 | .description-header h1 {
281 | font-size: 22px;
282 | font-weight: bold;
283 | margin: 0;
284 | color: #000;
285 | }
286 |
287 | .description-toggle {
288 | background: none;
289 | border: none;
290 | cursor: pointer;
291 | padding: 0;
292 | transition: transform 0.3s ease;
293 | margin-left: 6px;
294 | display: flex;
295 | align-items: center;
296 | }
297 |
298 | .description-toggle img {
299 | width: 15px;
300 | height: 15px;
301 | opacity: 0.7;
302 | transition: opacity 0.3s ease;
303 | }
304 |
305 | .description-toggle:hover img {
306 | opacity: 1;
307 | }
308 |
309 | .description-content {
310 | max-height: 0;
311 | overflow: hidden;
312 | transition: max-height 0.4s ease, opacity 0.3s ease, width 0.4s ease;
313 | opacity: 0;
314 | width: 0;
315 | }
316 |
317 | .description-content.expanded {
318 | max-height: 500px;
319 | opacity: 1;
320 | width: 100%;
321 | }
322 |
323 | .description-content p {
324 | font-size: 14px;
325 | color: #2396da;
326 | margin-bottom: 8px;
327 | margin-top: 4px;
328 | }
329 |
330 | .description-content ul {
331 | padding-left: 20px;
332 | margin: 0 0 8px;
333 | }
334 |
335 | .description-content li {
336 | margin-bottom: 4px;
337 | list-style-type: disc;
338 | color: #333;
339 | }
340 |
341 | .password-container {
342 | width: 350px;
343 | border: 1px solid #ddd;
344 | padding: 0px 20px;
345 | border-radius: 5px;
346 | background-color: #fff;
347 | margin-top: 20px;
348 | font-family: 'Roboto-Regular', sans-serif;
349 | }
350 |
351 | .password-container h2 {
352 | font-size: 18px;
353 | margin-bottom: 15px;
354 | color: #333;
355 | }
356 |
357 | #change-password-form {
358 | box-sizing: border-box;
359 | display: block;
360 | width: 100%;
361 | }
362 |
363 | .password-input {
364 | box-sizing: border-box;
365 | margin-bottom: 16px;
366 | max-width: 352px;
367 | width: 300px;
368 | position: relative;
369 | }
370 |
371 | .password-input label {
372 | box-sizing: border-box;
373 | border-radius: 2px;
374 | color: rgb(148, 155, 159);
375 | cursor: default;
376 | font-size: 12px;
377 | left: 12px;
378 | max-width: 85%;
379 | overflow: hidden;
380 | padding: 0px 2px;
381 | position: absolute;
382 | text-overflow: ellipsis;
383 | white-space: nowrap;
384 | top: -7px;
385 | transition: top 0.2s, font-size 0.2s;
386 | user-select: none;
387 | animation: 0.2s linear 0s 1 normal none running _ngcontent-uig-c80_delay-pointer-events;
388 | background-color: rgb(255, 255, 255);
389 | pointer-events: auto;
390 | }
391 |
392 | .password-input input {
393 | box-sizing: border-box;
394 | font-family: Roboto, sans-serif;
395 | font-size: 14px;
396 | background: none 0% 0% / auto repeat scroll padding-box border-box rgb(255, 255, 255);
397 | border: 1px solid rgb(235, 235, 235);
398 | border-radius: 4px;
399 | border-spacing: 2px;
400 | color: rgb(0, 0, 0);
401 | height: 40px;
402 | min-width: 75px;
403 | outline: rgb(0, 0, 0) none 0px;
404 | padding: 12px 35px 12px 12px;
405 | width: 100%;
406 | animation-name: _ngcontent-uig-c80_on-autofill-start;
407 | caret-color: rgb(0, 0, 0);
408 | transition: background-color 65536s ease-in-out;
409 | }
410 |
411 | .password-save-button {
412 | background-color: #2396da;
413 | color: white;
414 | border: 2px solid #2396da;
415 | border-radius: 5px;
416 | padding: 10px 20px;
417 | cursor: pointer;
418 | font-family: 'Roboto-Bold', sans-serif;
419 | font-size: 14px;
420 | transition: all 0.3s ease-in-out;
421 | }
422 |
423 | .password-save-button:hover {
424 | background-color: #1b7bb6;
425 | border-color: #1b7bb6;
426 | box-shadow: 0 0 10px rgba(35, 150, 218, 0.5);
427 | }
428 |
429 | #password-change-message {
430 | box-sizing: border-box;
431 | color: red;
432 | font-size: 14px;
433 | margin-top: 10px;
434 | text-align: center;
435 | }
436 |
437 | .dashboard-body {
438 | font-family: Arial, sans-serif;
439 | background-color: #f8f9fa;
440 | color: #333;
441 | margin: 0;
442 | padding: 20px;
443 | }
444 |
445 | .dashboard-container {
446 | width: 80%;
447 | margin: auto;
448 | background: white;
449 | padding: 20px;
450 | border-radius: 8px;
451 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
452 | display: flex;
453 | flex-direction: column;
454 | }
455 |
456 | .dashboard-h1 {
457 | text-align: center;
458 | margin-bottom: 20px;
459 | }
460 |
461 | .dashboard-policy-btn {
462 | padding: 10px;
463 | border: 1px solid #ddd;
464 | background-color: white;
465 | cursor: pointer;
466 | border-radius: 5px;
467 | text-align: left;
468 | width: 150px;
469 | display: flex;
470 | flex-direction: column;
471 | gap: 5px;
472 | transition: background-color 0.3s;
473 | }
474 |
475 | .dashboard-policy-btn.active {
476 | background-color: #f0f0f0;
477 | }
478 |
479 | .dashboard-policy-btn:hover {
480 | background-color: #f0f0f0;
481 | }
482 |
483 | .dashboard-policy-btn .polici-name {
484 | font-family: 'Roboto-Bold', sans-serif;
485 | }
486 |
487 | .dashboard-policy-btn .interface-name {
488 | font-size: 12px;
489 | color: #666;
490 | font-family: 'Roboto-Italic', sans-serif;
491 | word-break: break-word;
492 | }
493 |
494 | .dashboard {
495 | display: flex;
496 | align-items: flex-start;
497 | gap: 20px;
498 | }
499 |
500 | .dashboard-policies {
501 | display: flex;
502 | flex-direction: column;
503 | gap: 10px;
504 | }
505 |
506 | .dashboard-domain {
507 | display: flex;
508 | flex-direction: column;
509 | align-items: flex-start;
510 | flex-grow: 1;
511 | }
512 |
513 | .dashboard-domain .button-container {
514 | display: flex;
515 | gap: 10px;
516 | margin-top: 10px;
517 | }
518 |
519 | .dashboard-domain .button-container button {
520 | display: flex;
521 | align-items: center;
522 | justify-content: center;
523 | border: none;
524 | border-radius: 5px;
525 | padding: 8px 16px;
526 | cursor: pointer;
527 | font-family: 'Roboto-Bold', sans-serif;
528 | transition: background-color 0.3s ease;
529 | background-color: #2396da;
530 | color: white;
531 | }
532 |
533 | .dashboard-domain .button-container button:hover {
534 | background-color: #1b7bb6;
535 | }
536 |
537 | #dashboard-domains-container {
538 | width: 600px;
539 | border: 1px solid #ddd;
540 | padding: 10px;
541 | border-radius: 5px;
542 | background-color: #fff;
543 | min-height: 200px;
544 | font-family: 'Roboto-Regular', sans-serif;
545 | box-sizing: border-box;
546 | }
547 |
548 | #dashboard-add-field {
549 | background: none;
550 | border: none;
551 | color: #007bff;
552 | cursor: pointer;
553 | font-size: 14px;
554 | margin-top: 10px;
555 | text-align: left;
556 | padding: 5px 0;
557 | }
558 |
559 | .dashboard-buttons {
560 | margin-top: 20px;
561 | display: flex;
562 | justify-content: flex-start;
563 | gap: 10px;
564 | }
565 |
566 | .dashboard-button {
567 | font-family: 'Roboto-Bold', sans-serif;
568 | padding: 10px 20px;
569 | border: none;
570 | cursor: pointer;
571 | border-radius: 5px;
572 | width: 140px;
573 | height: 40px;
574 | text-align: center;
575 | display: flex;
576 | align-items: center;
577 | justify-content: center;
578 | transition: all 0.3s ease-in-out;
579 | }
580 |
581 | #dashboard-save {
582 | background-color: #2396da;
583 | color: white;
584 | border: 2px solid #2396da;
585 | }
586 |
587 | #dashboard-reset {
588 | background-color: white;
589 | color: #2396da;
590 | border: 2px solid #2396da;
591 | }
592 |
593 | .domain-entry-wrapper {
594 | display: flex;
595 | align-items: center;
596 | gap: 5px;
597 | width: 100%;
598 | margin-bottom: 10px;
599 | }
600 |
601 | .domain-entry {
602 | display: flex;
603 | align-items: center;
604 | }
605 |
606 | .domain-entry input[type="checkbox"] {
607 | position: absolute;
608 | opacity: 0;
609 | width: 16px;
610 | height: 16px;
611 | cursor: pointer;
612 | z-index: 1;
613 | }
614 |
615 | .domain-checkbox {
616 | width: 16px;
617 | height: 16px;
618 | border: 1px solid #d6d8d9;
619 | border-radius: 4px;
620 | background-color: #f0f0f0;
621 | display: flex;
622 | align-items: center;
623 | justify-content: center;
624 | transition: background-color 0.1s ease-in-out, border-color 0.1s ease-in-out;
625 | position: relative;
626 | flex-shrink: 0;
627 | margin-top: 0;
628 | }
629 |
630 | .domain-checkbox img {
631 | width: 10px;
632 | height: 8px;
633 | display: none;
634 | filter: invert(100%) brightness(100%) contrast(100%);
635 | }
636 |
637 | .domain-entry input[type="checkbox"]:checked + .domain-checkbox {
638 | background-color: #2396da;
639 | border-color: #2396da;
640 | }
641 |
642 | .domain-entry input[type="checkbox"]:checked + .domain-checkbox img {
643 | display: block;
644 | }
645 |
646 | .domain-content {
647 | flex: 1;
648 | width: calc(100% - 28px);
649 | }
650 |
651 | .domain-controls {
652 | display: flex;
653 | justify-content: flex-end;
654 | margin-bottom: 2px;
655 | width: calc(100% - 5px);
656 | }
657 |
658 | .domain-controls input.description {
659 | width: calc(100% - 5px);
660 | padding: 4px;
661 | margin: 0;
662 | text-align: left;
663 | border: none;
664 | border-bottom: 1px solid transparent;
665 | outline: none;
666 | font-family: 'Roboto-Bold', sans-serif;
667 | font-size: 14px;
668 | box-sizing: border-box;
669 | transition: border 0.3s ease;
670 | }
671 |
672 | .domain-controls input.description:focus {
673 | border-bottom: 1px solid #ccc;
674 | }
675 |
676 | .domain-entry textarea {
677 | width: calc(100% - 5px);
678 | height: 100px;
679 | padding: 8px;
680 | border: 1px solid #ddd;
681 | border-radius: 3px;
682 | font-family: 'Roboto-Regular', sans-serif;
683 | font-size: 14px;
684 | resize: none;
685 | margin-bottom: 3px;
686 | box-sizing: border-box;
687 | word-break: keep-all;
688 | }
689 |
690 | .remove-container {
691 | display: flex;
692 | justify-content: flex-end;
693 | width: calc(100% - 5px);
694 | margin-top: -5px;
695 | padding-right: 5px;
696 | }
697 |
698 | .remove {
699 | display: inline-flex;
700 | align-items: center;
701 | background: none;
702 | border: none;
703 | color: #ff4d4d;
704 | cursor: pointer;
705 | font-family: 'Roboto-Italic', sans-serif;
706 | font-size: 14px;
707 | padding: 2px 0;
708 | }
709 |
710 | .remove .delete-icon {
711 | width: 16px;
712 | height: 16px;
713 | margin-right: 5px;
714 | filter: brightness(0) saturate(100%) invert(26%) sepia(100%) saturate(7495%) hue-rotate(356deg) brightness(100%) contrast(120%);
715 | }
716 |
717 | #dashboard-save:hover {
718 | box-shadow: 0 0 10px rgba(35, 150, 218, 0.5);
719 | border-color: #1b7bb6;
720 | }
721 |
722 | #dashboard-reset:hover {
723 | box-shadow: 0 0 10px rgba(35, 150, 218, 0.5);
724 | border-color: #1b7bb6;
725 | }
726 |
727 | .remove:hover {
728 | opacity: 0.8;
729 | }
730 |
731 | .dashboard-buttons-container {
732 | display: flex;
733 | gap: 10px;
734 | margin-top: 10px;
735 | }
736 |
737 | #load-from-github,
738 | #load-from-url {
739 | background: none;
740 | border: none;
741 | color: #007bff;
742 | cursor: pointer;
743 | font-size: 14px;
744 | margin-top: 10px;
745 | text-align: left;
746 | padding: 5px 0;
747 | }
748 |
749 | .service-list-container {
750 | display: grid;
751 | grid-template-columns: repeat(2, 1fr); /* Две колонки */
752 | gap: 10px;
753 | }
754 |
755 | #service-list {
756 | list-style: none;
757 | padding: 0;
758 | margin: 0;
759 | display: contents;
760 | }
761 |
762 | #service-list li {
763 | margin: 5px 0;
764 | display: flex;
765 | align-items: center;
766 | }
767 |
768 | #dns-loader p {
769 | font-size: 16px;
770 | margin: 0;
771 | }
772 |
773 | #dns-progress {
774 | transition: width 0.15s linear;
775 | }
776 |
777 | #custom-url {
778 | width: 100%;
779 | padding: 10px 12px;
780 | margin-top: 10px;
781 | margin-bottom: 15px;
782 | font-size: 14px;
783 | font-family: 'Roboto-Regular', sans-serif;
784 | border: 1px solid #ccc;
785 | border-radius: 4px;
786 | box-sizing: border-box;
787 | outline: none;
788 | }
789 |
790 | #custom-url:focus {
791 | border-color: #2396da;
792 | box-shadow: 0 0 4px rgba(35, 150, 218, 0.4);
793 | }
794 |
795 | .checkbox-label-text {
796 | font-family: 'Roboto-Regular', sans-serif;
797 | font-size: 14px;
798 | color: #333;
799 | margin-left: 8px;
800 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Ground-Zerro
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/Neo/README.md:
--------------------------------------------------------------------------------
1 | # HydraRoute Neo
2 |
3 | **HydraRoute Neo** — следующая ступень развития HydraRoute.
4 |
5 | ---
6 |
7 | ## 📚 Оглавление
8 |
9 | - [🚀 Что умеет Neo?](#-что-умеет-neo)
10 | - [📋 Системные требования](#-системные-требования)
11 | - [📁 Конфигурация](#-конфигурация)
12 | - [Управление доменами](#-управление-доменами)
13 | - [Файл настроек](#файл-настроек)
14 | - [🌐 IPv6](#-ipv6)
15 | - [🔧 Управление](#-управление)
16 | - [🔍 Проверка и отладка](#-проверка-и-отладка)
17 | - [💾 Установка](#-установка)
18 | - [🔀 Многотуннельность](#-многотуннельность)
19 | - [🧬 Суммирование пропускной способности](#-суммирование-пропускной-способности)
20 | - [🔄 Обновление](#-обновление)
21 | - [❌ Удаление](#-удаление)
22 | - [⚙️ Принципы и этапы работы](#️-принципы-и-этапы-работы)
23 | - [☕ Donate](#-donate)
24 | - [⚠️ Дисклеймер](#️-дисклеймер)
25 |
26 | ---
27 |
28 | ## 🚀 Что умеет Neo?
29 |
30 | Neo поддерживает все возможности классической версии, а также:
31 |
32 | - **Не требует отключения системного DNS-сервера**.
33 | - **Пользователь сам задаёт имена и количество политик**.
34 | - **Поддержка IPv6 (включая ipset и ip6tables)**.
35 |
36 | > ⚠️ Проект представлен как концепт и служит для подтверждения жизнеспособности идеи, не являясь законченным продуктом.
37 | > Техническа поддержка - **не предусмотрена**.
38 |
39 | ---
40 |
41 | ## 📋 Системные требования
42 |
43 | Для установки и работы HydraRoute Neo необходимо:
44 |
45 | - Роутер Keenetic с установленным [Entware](https://help.keenetic.com/hc/ru/articles/360021214160-Установка-системы-пакетов-репозитория-Entware-на-USB-накопитель)
46 | - Установленный пакет `curl`:
47 | ```
48 | opkg install curl
49 | ```
50 |
51 | ---
52 |
53 | ## 📁 Конфигурация
54 |
55 | ### 🌍 Управление доменами
56 |
57 | #### Web интерфейс:
58 | Управление доменами доступно в [**web4static**](https://github.com/spatiumstas/web4static) (Благодарность разработчику - [spatiumstas](https://github.com/spatiumstas))
59 |
60 | Установка:
61 | ```
62 | opkg update && opkg install curl && curl -L -s "https://raw.githubusercontent.com/spatiumstas/web4static/main/install.sh" > /tmp/install.sh && sh /tmp/install.sh
63 | ```
64 |
65 | #### Вручную:
66 | Файл доменов: `/opt/etc/HydraRoute/domain.conf`
67 |
68 | Формат:
69 | ```
70 | example.com,domain.net/PolicyName
71 | google.com,youtube.com/Warp
72 | ```
73 |
74 | - Разделитель — **запятая**.
75 | - После слеша — **имя политики**.
76 | - Пробелы в строках — **не допускаются**.
77 | - Домены в разных строках **не должны пересекаться**.
78 |
79 | ### Файл настроек:
80 | `/opt/etc/HydraRoute/hrneo.conf`
81 |
82 | Конфигурация по умолчанию:
83 | ```
84 | watchlistPath=/opt/etc/HydraRoute/domain.conf
85 | interfaceName=br0
86 | reconnect=true
87 | log=false
88 | logfile=/opt/var/log/hrneo.log
89 | clearIPSet=true
90 | ```
91 |
92 | - `watchlistPath` — полный путь к файлу со списком доменов.
93 | - `interfaceName` — системный интерфейс для отслеживания. Укажите `any`, если нужно отслеживать все. Менять интерфейс **не рекомендуется**.
94 | - `reconnect` — закрывать существующие подключения к IP при первом его добавлении в ipset: `true`, `false`.
95 | - `log` — уровень логирования: `console`, `file`, `false`. Включать лог без целей отладки **не рекомендуется**.
96 | - `logfile` — путь к лог-файлу, если `log=file`.
97 | - `clearIPSet` — очищать ipset при запуске/перезапуске: `true`, `false`.
98 |
99 | ---
100 |
101 | ## 🌐 IPv6
102 |
103 | Поддержка IPv6 имеется.
104 | Если не используете — отключите IPv6 в настройках подклюения провайдера и/или VPN соединения.
105 | 👉 Для работы [IPv6 через VPN](https://yandex.ru/search/?text=Для+работы+IPv6+через+VPN&clid=6799014&banerid=6500000000&win=672&lr=79) необходимо соблюдение всех четырех условий одновременно:
106 | - ipv6 должен быть у основного провайдера
107 | - ipv6 должен быть у VPN сервера
108 | - ipv6 должен быть у `WG` (`PPTP`, `L2TP`, `OpenVPN` etc.) пира
109 | - ipv6 маршрутизация должна быть настроена на VPS
110 |
111 | ---
112 |
113 | ## 🔧 Управление
114 |
115 | | Команда | Описание |
116 | |:--------------|:-----------------|
117 | | `neo status` | Проверить статус |
118 | | `neo start` | Запуск |
119 | | `neo stop` | Остановка |
120 | | `neo restart` | Перезапуск |
121 |
122 | Альтернативно:
123 | ```
124 | /opt/etc/init.d/S99hrneo status|start|stop|restart
125 | ```
126 |
127 | ---
128 |
129 | ## 🔍 Проверка и отладка
130 |
131 | ### 🚦 iptables:
132 |
133 | Проверить наличие правил в iptables
134 | ```
135 | iptables -t mangle -S | grep -E 'HydraRoute' # IPv4
136 | ip6tables -t mangle -S | grep -E 'HydraRoute' # IPv6
137 | ```
138 |
139 | ### 🗃️ ipset:
140 |
141 | Проверить наполняется ли IPset
142 | ```
143 | ipset list HydraRoute # IPv4
144 | ipset list HydraRoutev6 # IPv6
145 | ```
146 |
147 | ### 🧹 Очистка ipset:
148 |
149 | Очистить ipset от накопленных IP-адресов
150 | ```
151 | ipset flush HydraRoute # IPv4
152 | ipset flush HydraRoutev6 # IPv6
153 | ```
154 | - или протсо [перезапустить HydraRoute Neo](#-управление)
155 |
156 | > 👉 Замените `HydraRoute` на **название Вашей политики**
157 |
158 | ---
159 |
160 | ## 💾 Установка
161 |
162 | 1. 📦 Добавить [репозиторий](https://ground-zerro.github.io/release/) в Entware:
163 | ```
164 | curl -Ls "https://ground-zerro.github.io/release/keenetic/install-feed.sh" | sh
165 | ```
166 |
167 | 2. 🚀 Установить HydraRoute Neo:
168 | ```
169 | opkg install hrneo
170 | ```
171 |
172 | > 👉 HydraRoute Neo готов к работе сразу после установки. Служба запускается автоматически.
173 |
174 | ---
175 |
176 | ## 🔀 Многотуннельность
177 |
178 | Для перенаправления отдельных доменов в разные отдельные туннели, создайте отдельные
179 | строки в `domain.conf` с разными именами политик (например, `/Warp`, `/Obhod`, `/Zakop` и т.д.).
180 |
181 | После [перезапуска службы HydraRoute Neo](#-управление) политика будет создана автоматически.
182 | 👉 В Web-интерфейсе роутера нужно указать и активировать требуемое подключение для новой политики.
183 |
184 | ---
185 |
186 | ## 🧬 Суммирование пропускной способности
187 |
188 | В одной политике можно указать несколько VPN-подключений одновременно, активировав [режим многопутевой маршрутизации Keenetic](https://help.keenetic.com/hc/ru/articles/7490633500572-Многопутевая-передача-суммирование-пропускной-способности-нескольких-интернет-соединений).
189 | 👉 В этом режиме, все включенные в политику подключения передают трафик агрегируя пропускную способности каналов.
190 |
191 | ---
192 |
193 | ## 🔄 Обновление
194 |
195 | Конмада для обновления установленных пакетов:
196 | ```
197 | opkg update && opkg upgrade
198 | ```
199 |
200 | ---
201 |
202 | ## ❌ Удаление
203 |
204 | Стандартно:
205 | ```
206 | opkg remove hrneo
207 | ```
208 |
209 | Полное удаление (в т.ч. файлы, логи etc.) c откатом всех изменений в системе к стандартным:
210 | ```
211 | curl -Ls "https://ground-zerro.github.io/release/keenetic/hr-uninstall.sh" | sh
212 | ```
213 |
214 | ---
215 |
216 | ## ⚙️ Принципы и этапы работы
217 |
218 | 1. **Загрузка конфигурации**
219 | Загрузка настроек: интерфейс, лог-файл, домены.
220 |
221 | 2. **Формирование IPSET-групп**
222 | Создание ipset-групп под IPv4/IPv6 по каждому домену.
223 |
224 | 3. **Создание политик маршрутизации**
225 | Проверка и автоматическое создание политик через `ndmc`.
226 |
227 | 4. **Маршрутизация трафика**
228 | Установка правил iptables/ip6tables с использованием `CONNMARK`.
229 |
230 | 5. **Анализ DNS-запросов**
231 | Отслеживание DNS и добавление IP-адресов доменов в ipset при совпадении.
232 |
233 | 6. **Контроль и настройка**
234 | Закрытие сессий, поддержка и обновление правил маршрутизации, ведение лога, настройка через конфиг.
235 |
236 | ---
237 |
238 | ## ☕ Donate
239 |
240 | Если HydraRoute Neo оказался полезным — можно отблагодарить автора:
241 |
242 | - [Угостив](https://boosty.to/ground_zerro/donate) кружечкой горячего какао 😋
243 | - Став [подписчиком](https://boosty.to/ground_zerro)
244 |
245 | ---
246 |
247 | ## ⚠️ Дисклеймер
248 |
249 | > Автор не несёт ответственности за любые последствия. Используя данный скрипт, вы действуете на свой страх и риск.
250 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # HydraRoute
2 |
3 | **HydraRoute** — инструмент для раздельной маршрутизации трафика по доменам с использованием VPN на роутерах **Keenetic**.
4 |
5 | 💡 Трафик к указанным доменам отправляется через VPN, а всё остальное — напрямую.
6 | Управление политиками — через Web-интерфейс роутера или конфигурационные файлы.
7 |
8 | ---
9 |
10 | ## 🚀 Возможности
11 |
12 | - Перенаправление трафика отдельных доменов через VPN.
13 | - Поддержка нескольких политики и маршрутизации в разные туннели.
14 | - Поддержка IPv6 и ip6tables (в Neo).
15 | - Настройка через Web-интерфейс или вручную.
16 | - Поддержка мульти-WAN и агрегации каналов.
17 | - Защищенные DNS через TLS.
18 | - Возможность суммирования пропускной способности каналов.
19 | - Перенаправление отдельных доменов через разные VPN.
20 | - Совместимость с WARP.
21 | - Фильтрация рекламы (в Classic).
22 |
23 | ---
24 |
25 | ## 🧬 Версии HydraRoute
26 |
27 | ### 🔹 Classic
28 |
29 | - Простота установки и управления.
30 | - Управление подключениями через Web-интерфейс Keenetic.
31 | - Редактирование списков доменов в Web-интерфейсе RydraRoute.
32 | - Поддержка до 3х предустановленных политик.
33 | - Интеграция IPset с AdGuard Home.
34 | - Подходит для большинства пользователей.
35 |
36 | [Подробнее →](https://github.com/Ground-Zerro/HydraRoute/tree/main/Classic)
37 |
38 | ---
39 |
40 | ### 🔸 Neo
41 |
42 | - Для продвинутых пользователей.
43 | - Не требует отключения системного DNS.
44 | - Пользователь сам задаёт названия и количество политик.
45 | - Полная поддержка IPv6.
46 |
47 | [Подробнее →](https://github.com/Ground-Zerro/HydraRoute/tree/main/Neo)
48 |
49 | ⚠️ *Neo — это концепт и подтверждение жизнеспособности подхода. Поддержка ограничена.*
50 |
51 | ---
52 |
53 | ## 📋 Требования
54 |
55 | - Роутер с KeenOS
56 | - Entware (установлен и настроен)
57 | - Настроенное VPN-подключение (WireGuard, OpenVPN, etc.)
58 | - Установленный `curl`
59 |
60 | ---
61 |
62 | ## 🧭 Планы на будущее
63 |
64 | - Поддержка vless
65 | - Интеграция с [zapret](https://github.com/bol-van/zapret)
66 | - Обновления из WebUI
67 |
68 | ---
69 |
70 | ## ☕ Поддержка
71 |
72 | Если проект оказался Вам полезен — можно поддержать автора:
73 |
74 | - [Поддержать на Boosty](https://boosty.to/ground_zerro)
75 |
--------------------------------------------------------------------------------
/Relic/README.md:
--------------------------------------------------------------------------------
1 | **Эта версия более не поддерживается! Инструкции могуть быть не актуальны!**
2 |
3 | # HydraRoute v.0.0.1b(202501300900)
4 |
5 | **Основная цель** — перенаправление трафика к **отдельным доменам** через VPN. Все, что не указано в списке, будет открываться напрямую.
6 |
7 | ## Установка:
8 | 1. Подключитесь к роутеру по SSH (к Entware).
9 | 2. Выполните команду:
10 | ```
11 | curl -L -s "https://github.com/Ground-Zerro/HydraRoute/raw/refs/heads/main/Relic/hydraroute.sh" > /opt/tmp/hydraroute.sh && chmod +x /opt/tmp/hydraroute.sh && /opt/tmp/hydraroute.sh
12 | ```
13 | 3. Выберите VPN из списка.
14 |
15 | ## Дополнительная информация:
16 | ### Как добавить домены в ipset
17 |
18 | 1. Через web-панель.
19 | - web-панель доступна по адресу: [http://192.168.1.1:2000/](http://192.168.1.1:2000/)
20 | * (где `192.168.1.1` - это IP-адрес роутера)
21 | 2. Вручную, правкой файла `ipset.conf`.
22 |
23 |
24 | нажать, чтобы прочесть подробней
25 |
26 | 1. Чтобы добавить домены для перенаправления, отредактируйте файл: `/opt/etc/AdGuardHome/ipset.conf`.
27 | ```
28 | nano /opt/etc/AdGuardHome/ipset.conf
29 | ```
30 |
31 |
32 | Синтаксис файла ipset.conf (нажать, чтобы прочесть подробней)
33 |
34 | ```
35 | instagram.com,cdninstagram.com/bypass,bypass6
36 | openai.com,chatgpt.com/bypass,bypass6
37 | ```
38 | - В левой части через запятую указаны домены, требующие обхода.
39 | - Справа после слэша — ipset, в который AGH складывает результаты разрешения DNS-имён. В примере указаны созданные скриптом `ipset` для IPv4 и IPv6: `/bypass,bypass6`.
40 | - Можно указать всё в одну строчку, можно разделить логически на несколько строк, как в примере.
41 | - Домены третьего уровня и выше включаются сами, т.е. указание `intel.com` включает также `www.intel.com`, `download.intel.com` и прочее.
42 |
43 | 2. После добавления доменов необходимо перезапустить **AdGuard Home** командой:
44 | ```
45 | /opt/etc/init.d/S99adguardhome restart
46 | ```
47 |
48 |
49 | ## Удаление:
50 | ```
51 | curl -Ls "https://ground-zerro.github.io/release/keenetic/hr-uninstall.sh" | sh
52 | ```
53 |
--------------------------------------------------------------------------------
/Relic/hydraroute.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | # Служебные функции и переменные
4 | LOG="/opt/var/log/HydraRoute.log"
5 | echo "$(date "+%Y-%m-%d %H:%M:%S") Запуск установки" >> "$LOG"
6 | REQUIRED_VERSION="4.2.3"
7 | IP_ADDRESS=$(ip addr show br0 | grep 'inet ' | awk '{print $2}' | cut -d/ -f1)
8 | VERSION=$(ndmc -c show version | grep "title" | awk -F": " '{print $2}')
9 | AVAILABLE_SPACE=$(df /opt | awk 'NR==2 {print $4}')
10 | ## переменные для конфига AGH
11 | PASSWORD=\$2y\$10\$fpdPsJjQMGNUkhXgalKGluJ1WFGBO6DKBJupOtBxIzckpJufHYpk.
12 | rule1='||*^$dnstype=HTTPS,dnsrewrite=NOERROR'
13 | rule2='||yabs.yandex.ru^$important'
14 | rule3='||mc.yandex.ru^$important'
15 | ## анимация
16 | animation() {
17 | local pid=$1
18 | local message=$2
19 | local spin='-\|/'
20 |
21 | echo -n "$message... "
22 |
23 | while kill -0 $pid 2>/dev/null; do
24 | for i in $(seq 0 3); do
25 | echo -ne "\b${spin:$i:1}"
26 | usleep 100000 # 0.1 сек
27 | done
28 | done
29 |
30 | wait $pid
31 | if [ $? -eq 0 ]; then
32 | echo -e "\b✔ Готово!"
33 | else
34 | echo -e "\b✖ Ошибка!"
35 | fi
36 | }
37 |
38 | # Получение списка и выбор интерфейса
39 | get_interfaces() {
40 | ## выводим список интерфейсов для выбора
41 | echo "Доступные интерфейсы:"
42 | i=1
43 | interfaces=$(ip a | sed -n 's/.*: \(.*\): <.*UP.*/\1/p')
44 | interface_list=""
45 | for iface in $interfaces; do
46 | ## проверяем, существует ли интерфейс, игнорируя ошибки 'ip: can't find device'
47 | if ip a show "$iface" &>/dev/null; then
48 | ip_address=$(ip a show "$iface" | grep -oP 'inet \K[\d.]+')
49 |
50 | if [ -n "$ip_address" ]; then
51 | echo "$i. $iface: $ip_address"
52 | interface_list="$interface_list $iface"
53 | i=$((i+1))
54 | fi
55 | fi
56 | done
57 |
58 | ## запрашиваем у пользователя имя интерфейса с проверкой ввода
59 | while true; do
60 | read -p "Введите ИМЯ интерфейса, через которое будет перенаправляться трафик: " net_interface
61 |
62 | if echo "$interface_list" | grep -qw "$net_interface"; then
63 | echo "Выбран интерфейс: $net_interface"
64 | break
65 | else
66 | echo "Неверный выбор, необходимо ввести ИМЯ интерфейса из списка."
67 | fi
68 | done
69 | }
70 |
71 | # Установка пакетов
72 | opkg_install() {
73 | opkg update
74 | opkg install adguardhome-go ipset iptables ip-full
75 | }
76 |
77 | # Формирование файлов
78 | files_create() {
79 | ## ipset
80 | cat << EOF > /opt/etc/init.d/S52ipset
81 | #!/bin/sh
82 |
83 | PATH=/opt/sbin:/opt/bin:/opt/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
84 |
85 | if [ "\$1" = "start" ]; then
86 | ipset create bypass hash:ip
87 | ipset create bypass6 hash:ip family inet6
88 | ip rule add fwmark 1001 table 1001
89 | ip -6 rule add fwmark 1001 table 1001
90 | fi
91 | EOF
92 |
93 | ## скрипты маршрутизации
94 | cat << EOF > /opt/etc/ndm/ifstatechanged.d/010-bypass-table.sh
95 | #!/bin/sh
96 |
97 | [ "\$system_name" == "$net_interface" ] || exit 0
98 | [ ! -z "\$(ipset --quiet list bypass)" ] || exit 0
99 | [ "\${connected}-\${link}-\${up}" == "yes-up-up" ] || exit 0
100 |
101 | if [ -z "\$(ip route list table 1001)" ]; then
102 | ip route add default dev \$system_name table 1001
103 | fi
104 | EOF
105 |
106 | cat << EOF > /opt/etc/ndm/ifstatechanged.d/011-bypass6-table.sh
107 | #!/bin/sh
108 |
109 | [ "\$system_name" == "$net_interface" ] || exit 0
110 | [ ! -z "\$(ipset --quiet list bypass6)" ] || exit 0
111 | [ "\${connected}-\${link}-\${up}" == "yes-up-up" ] || exit 0
112 |
113 | if [ -z "\$(ip -6 route list table 1001)" ]; then
114 | ip -6 route add default dev \$system_name table 1001
115 | fi
116 | EOF
117 |
118 | ## cкрипты маркировки трафика
119 | cat << EOF > /opt/etc/ndm/netfilter.d/010-bypass.sh
120 | #!/bin/sh
121 |
122 | [ "\$type" == "ip6tables" ] && exit
123 | [ "\$table" != "mangle" ] && exit
124 | [ -z "\$(ip link list | grep $net_interface)" ] && exit
125 | [ -z "\$(ipset --quiet list bypass)" ] && exit
126 |
127 | if [ -z "\$(iptables-save | grep bypass)" ]; then
128 | iptables -w -t mangle -A PREROUTING ! -i $net_interface -m conntrack --ctstate NEW -m set --match-set bypass dst -j CONNMARK --set-mark 1001
129 | iptables -w -t mangle -A PREROUTING ! -i $net_interface -m set --match-set bypass dst -j CONNMARK --restore-mark
130 | fi
131 | EOF
132 |
133 | cat << EOF > /opt/etc/ndm/netfilter.d/011-bypass6.sh
134 | #!/bin/sh
135 |
136 | [ "\$type" != "ip6tables" ] && exit
137 | [ "\$table" != "mangle" ] && exit
138 | [ -z "\$(ip -6 link list | grep $net_interface)" ] && exit
139 | [ -z "\$(ipset --quiet list bypass6)" ] && exit
140 |
141 | if [ -z "\$(ip6tables-save | grep bypass6)" ]; then
142 | ip6tables -w -t mangle -A PREROUTING ! -i $net_interface -m conntrack --ctstate NEW -m set --match-set bypass6 dst -j CONNMARK --set-mark 1001
143 | ip6tables -w -t mangle -A PREROUTING ! -i $net_interface -m set --match-set bypass6 dst -j CONNMARK --restore-mark
144 | fi
145 | EOF
146 | }
147 |
148 | # Настройки AGH
149 | agh_setup() {
150 | /opt/etc/init.d/S99adguardhome stop
151 | ## конфиг AdGuard Home
152 | cat << EOF > /opt/etc/AdGuardHome/AdGuardHome.yaml
153 | http:
154 | pprof:
155 | port: 6060
156 | enabled: false
157 | address: $IP_ADDRESS:3000
158 | session_ttl: 720h
159 | users:
160 | - name: admin
161 | password: $PASSWORD
162 | auth_attempts: 5
163 | block_auth_min: 15
164 | http_proxy: ""
165 | language: ""
166 | theme: auto
167 | dns:
168 | bind_hosts:
169 | - 0.0.0.0
170 | port: 53
171 | anonymize_client_ip: false
172 | ratelimit: 20
173 | ratelimit_subnet_len_ipv4: 24
174 | ratelimit_subnet_len_ipv6: 56
175 | ratelimit_whitelist: []
176 | refuse_any: true
177 | upstream_dns:
178 | - tls://dns.google
179 | - tls://one.one.one.one
180 | - tls://p0.freedns.controld.com
181 | - tls://dot.sb
182 | - tls://dns.nextdns.io
183 | - tls://dns.quad9.net
184 | upstream_dns_file: ""
185 | bootstrap_dns:
186 | - 9.9.9.9
187 | - 1.1.1.1
188 | - 8.8.8.8
189 | - 149.112.112.10
190 | - 94.140.14.14
191 | fallback_dns: []
192 | upstream_mode: load_balance
193 | fastest_timeout: 1s
194 | allowed_clients: []
195 | disallowed_clients: []
196 | blocked_hosts:
197 | - version.bind
198 | - id.server
199 | - hostname.bind
200 | trusted_proxies:
201 | - 127.0.0.0/8
202 | - ::1/128
203 | cache_size: 4194304
204 | cache_ttl_min: 0
205 | cache_ttl_max: 0
206 | cache_optimistic: false
207 | bogus_nxdomain: []
208 | aaaa_disabled: false
209 | enable_dnssec: false
210 | edns_client_subnet:
211 | custom_ip: ""
212 | enabled: false
213 | use_custom: false
214 | max_goroutines: 300
215 | handle_ddr: true
216 | ipset: []
217 | ipset_file: /opt/etc/AdGuardHome/ipset.conf
218 | bootstrap_prefer_ipv6: false
219 | upstream_timeout: 10s
220 | private_networks: []
221 | use_private_ptr_resolvers: true
222 | local_ptr_upstreams: []
223 | use_dns64: false
224 | dns64_prefixes: []
225 | serve_http3: false
226 | use_http3_upstreams: false
227 | serve_plain_dns: true
228 | hostsfile_enabled: true
229 | tls:
230 | enabled: false
231 | server_name: ""
232 | force_https: false
233 | port_https: 443
234 | port_dns_over_tls: 853
235 | port_dns_over_quic: 853
236 | port_dnscrypt: 0
237 | dnscrypt_config_file: ""
238 | allow_unencrypted_doh: false
239 | certificate_chain: ""
240 | private_key: ""
241 | certificate_path: ""
242 | private_key_path: ""
243 | strict_sni_check: false
244 | querylog:
245 | dir_path: ""
246 | ignored: []
247 | interval: 24h
248 | size_memory: 1000
249 | enabled: false
250 | file_enabled: true
251 | statistics:
252 | dir_path: ""
253 | ignored: []
254 | interval: 24h
255 | enabled: false
256 | filters:
257 | - enabled: true
258 | url: https://adguardteam.github.io/HostlistsRegistry/assets/filter_1.txt
259 | name: AdGuard DNS filter
260 | id: 1
261 | - enabled: true
262 | url: https://adguardteam.github.io/HostlistsRegistry/assets/filter_2.txt
263 | name: AdAway Default Blocklist
264 | id: 2
265 | - enabled: true
266 | url: https://adguardteam.github.io/HostlistsRegistry/assets/filter_59.txt
267 | name: AdGuard DNS Popup Hosts filter
268 | id: 1737211801
269 | - enabled: true
270 | url: https://adguardteam.github.io/HostlistsRegistry/assets/filter_30.txt
271 | name: Phishing URL Blocklist (PhishTank and OpenPhish)
272 | id: 1737211802
273 | - enabled: true
274 | url: https://adguardteam.github.io/HostlistsRegistry/assets/filter_42.txt
275 | name: ShadowWhisperer's Malware List
276 | id: 1737211803
277 | - enabled: true
278 | url: https://adguardteam.github.io/HostlistsRegistry/assets/filter_9.txt
279 | name: The Big List of Hacked Malware Web Sites
280 | id: 1737211804
281 | - enabled: true
282 | url: https://adguardteam.github.io/HostlistsRegistry/assets/filter_63.txt
283 | name: HaGeZi's Windows/Office Tracker Blocklist
284 | id: 1737211805
285 | - enabled: true
286 | url: https://adguardteam.github.io/HostlistsRegistry/assets/filter_7.txt
287 | name: Perflyst and Dandelion Sprout's Smart-TV Blocklist
288 | id: 1737211806
289 | - enabled: true
290 | url: https://adguardteam.github.io/HostlistsRegistry/assets/filter_12.txt
291 | name: Dandelion Sprout's Anti-Malware List
292 | id: 1737211807
293 | whitelist_filters: []
294 | user_rules:
295 | - '$rule1'
296 | - '$rule2'
297 | - '$rule3'
298 | dhcp:
299 | enabled: false
300 | interface_name: ""
301 | local_domain_name: lan
302 | dhcpv4:
303 | gateway_ip: ""
304 | subnet_mask: ""
305 | range_start: ""
306 | range_end: ""
307 | lease_duration: 86400
308 | icmp_timeout_msec: 1000
309 | options: []
310 | dhcpv6:
311 | range_start: ""
312 | lease_duration: 86400
313 | ra_slaac_only: false
314 | ra_allow_slaac: false
315 | filtering:
316 | blocking_ipv4: ""
317 | blocking_ipv6: ""
318 | blocked_services:
319 | schedule:
320 | time_zone: Local
321 | ids: []
322 | protection_disabled_until: null
323 | safe_search:
324 | enabled: false
325 | bing: true
326 | duckduckgo: true
327 | ecosia: true
328 | google: true
329 | pixabay: true
330 | yandex: true
331 | youtube: true
332 | blocking_mode: default
333 | parental_block_host: family-block.dns.adguard.com
334 | safebrowsing_block_host: standard-block.dns.adguard.com
335 | rewrites: []
336 | safe_fs_patterns:
337 | - /opt/etc/AdGuardHome/userfilters/*
338 | safebrowsing_cache_size: 1048576
339 | safesearch_cache_size: 1048576
340 | parental_cache_size: 1048576
341 | cache_time: 30
342 | filters_update_interval: 24
343 | blocked_response_ttl: 10
344 | filtering_enabled: true
345 | parental_enabled: false
346 | safebrowsing_enabled: false
347 | protection_enabled: true
348 | clients:
349 | runtime_sources:
350 | whois: true
351 | arp: true
352 | rdns: true
353 | dhcp: true
354 | hosts: true
355 | persistent: []
356 | log:
357 | enabled: true
358 | file: ""
359 | max_backups: 0
360 | max_size: 100
361 | max_age: 3
362 | compress: false
363 | local_time: false
364 | verbose: false
365 | os:
366 | group: ""
367 | user: ""
368 | rlimit_nofile: 0
369 | schema_version: 29
370 | EOF
371 | }
372 |
373 | # Базовый список доменов
374 | domain_add() {
375 | cat << EOF > /opt/etc/AdGuardHome/ipset.conf
376 | 2ip.ru/bypass,bypass6
377 | googlevideo.com,ggpht.com,googleapis.com,googleusercontent.com,gstatic.com,nhacmp3youtube.com,youtu.be,youtube.com,ytimg.com/bypass,bypass6
378 | cdninstagram.com,instagram.com,bookstagram.com,carstagram.com,chickstagram.com,ig.me,igcdn.com,igsonar.com,igtv.com,imstagram.com,imtagram.com,instaadder.com,instachecker.com,instafallow.com,instafollower.com,instagainer.com,instagda.com,instagify.com,instagmania.com,instagor.com,instagram.fkiv7-1.fna.fbcdn.net,instagram-brand.com,instagram-engineering.com,instagramhashtags.net,instagram-help.com,instagramhilecim.com,instagramhilesi.org,instagramium.com,instagramizlenme.com,instagramkusu.com,instagramlogin.com,instagrampartners.com,instagramphoto.com,instagram-press.com,instagram-press.net,instagramq.com,instagramsepeti.com,instagramtips.com,instagramtr.com,instagy.com,instamgram.com,instanttelegram.com,instaplayer.net,instastyle.tv,instgram.com,oninstagram.com,onlineinstagram.com,online-instagram.com,web-instagram.net,wwwinstagram.com/bypass,bypass6
379 | 1337x.to,262203.game4you.top,eztv.re,fitgirl-repacks.site,new.megashara.net,nnmclub.to,nnm-club.to,nnm-club.me,rarbg.to,rustorka.com,rutor.info,rutor.org,rutracker.cc,rutracker.org,tapochek.net,thelastgame.ru,thepiratebay.org,thepirate-bay.org,torrentgalaxy.to,torrent-games.best,torrentz2eu.org,limetorrents.info,pirateproxy-bay.com,torlock.com,torrentdownloads.me/bypass,bypass6
380 | chatgpt.com,openai.com,oaistatic.com,files.oaiusercontent.com,gpt3-openai.com,openai.fund,openai.org/bypass,bypass6
381 | EOF
382 | }
383 |
384 | # Установка прав на скрипты
385 | chmod_set() {
386 | chmod +x /opt/etc/init.d/S52ipset
387 | chmod +x /opt/etc/ndm/ifstatechanged.d/010-bypass-table.sh
388 | chmod +x /opt/etc/ndm/ifstatechanged.d/011-bypass6-table.sh
389 | chmod +x /opt/etc/ndm/netfilter.d/010-bypass.sh
390 | chmod +x /opt/etc/ndm/netfilter.d/011-bypass6.sh
391 | }
392 |
393 | # Установка web-панели
394 | install_panel() {
395 | opkg install node tar
396 | mkdir -p /opt/tmp
397 | /opt/etc/init.d/S99hpanel stop
398 | chmod -R 777 /opt/etc/HydraRoute/
399 | chmod 777 /opt/etc/init.d/S99hpanel
400 | rm -rf /opt/etc/HydraRoute/
401 | rm -r /opt/etc/init.d/S99hpanel
402 | curl -Ls --retry 6 --retry-delay 5 --max-time 5 -o /opt/tmp/hpanel.tar "https://github.com/Ground-Zerro/HydraRoute/raw/refs/heads/main/Relic/webpanel/hpanel.tar"
403 | if [ $? -ne 0 ]; then
404 | exit 1
405 | fi
406 | mkdir -p /opt/etc/HydraRoute
407 | tar -xf /opt/tmp/hpanel.tar -C /opt/etc/HydraRoute/
408 | rm /opt/tmp/hpanel.tar
409 | mv /opt/etc/HydraRoute/S99hpanel /opt/etc/init.d/S99hpanel
410 | chmod -R 444 /opt/etc/HydraRoute/
411 | chmod 755 /opt/etc/init.d/S99hpanel
412 | chmod 755 /opt/etc/HydraRoute/hpanel.js
413 | }
414 |
415 | # Отключение ipv6 на провайдере
416 | disable_ipv6() {
417 | curl -kfsS "localhost:79/rci/show/interface/" | jq -r '
418 | to_entries[] |
419 | select(.value.defaultgw == true or .value.via != null) |
420 | if .value.via then "\(.value.id) \(.value.via)" else "\(.value.id)" end
421 | ' | while read -r iface via; do
422 | ndmc -c "no interface $iface ipv6 address"
423 | if [ -n "$via" ]; then
424 | ndmc -c "no interface $via ipv6 address"
425 | fi
426 | done
427 | ndmc -c 'system configuration save'
428 | }
429 |
430 | # Проверка версии прошивки
431 | firmware_check() {
432 | if [ "$(printf '%s\n' "$VERSION" "$REQUIRED_VERSION" | sort -V | tail -n1)" = "$VERSION" ]; then
433 | dns_off >>"$LOG" 2>&1 &
434 | else
435 | dns_off_sh
436 | fi
437 | }
438 |
439 | # Отклчюение системного DNS
440 | dns_off() {
441 | ndmc -c 'opkg dns-override'
442 | ndmc -c 'system configuration save'
443 | sleep 3
444 | }
445 |
446 | # Отключение системного DNS через "nohup"
447 | dns_off_sh() {
448 | opkg install coreutils-nohup >>"$LOG" 2>&1
449 | echo "Отключение системного DNS..."
450 | echo ""
451 | if [ "$PANEL" = "1" ]; then
452 | complete_info
453 | else
454 | complete_info_no_panel
455 | fi
456 | rm -- "$0"
457 | read -r
458 | /opt/bin/nohup sh -c "ndmc -c 'opkg dns-override' && ndmc -c 'system configuration save' && sleep 3 && reboot" >>"$LOG" 2>&1
459 | }
460 |
461 | # Сообщение установка ОK
462 | complete_info() {
463 | echo "Установка HydraRoute завершена"
464 | echo " - панель управления доступна по адресу: http://$IP_ADDRESS:2000/"
465 | echo ""
466 | echo "Нажмите Enter для перезагрузки (обязательно)."
467 | }
468 |
469 | # Сообщение установка без панели
470 | complete_info_no_panel() {
471 | echo "HydraRoute установлен, но для web-панели не достаточно места"
472 | echo " - редактирование ipset возможно только вручную (инструкция на GitHub)."
473 | echo ""
474 | echo "AdGuard Home доступен по адресу: http://$IP_ADDRESS:3000/"
475 | echo "Login: admin"
476 | echo "Password: keenetic"
477 | echo ""
478 | echo "Нажмите Enter для перезагрузки (обязательно)."
479 | }
480 |
481 | # === main ===
482 | # Выход если места меньше 80Мб
483 | if [ "$AVAILABLE_SPACE" -lt 81920 ]; then
484 | echo "Не достаточно места для установки"
485 | rm -- "$0"
486 | exit 1
487 | fi
488 |
489 | # Запрос интерфейса у пользователя
490 | get_interfaces
491 |
492 | # Установка пакетов
493 | opkg_install >>"$LOG" 2>&1 &
494 | animation $! "Установка необходимых пакетов"
495 |
496 | # Формирование скриптов
497 | files_create >>"$LOG" 2>&1 &
498 | animation $! "Формируем скрипты"
499 |
500 | # Настройка AdGuard Home
501 | agh_setup >>"$LOG" 2>&1 &
502 | animation $! "Настройка AdGuard Home"
503 |
504 | # Добавление доменов в ipset
505 | domain_add >>"$LOG" 2>&1 &
506 | animation $! "Добавление доменов в ipset"
507 |
508 | # Установка прав на выполнение скриптов
509 | chmod_set >>"$LOG" 2>&1 &
510 | animation $! "Установка прав на выполнение скриптов"
511 |
512 | # установка web-панели если места больше 80Мб
513 | if [ "$AVAILABLE_SPACE" -gt 81920 ]; then
514 | PANEL="1"
515 | install_panel >>"$LOG" 2>&1 &
516 | animation $! "Установка web-панели"
517 | fi
518 |
519 | # Отключение ipv6
520 | disable_ipv6 >>"$LOG" 2>&1 &
521 | animation $! "Отключение ipv6"
522 |
523 | # Отключение системного DNS и сохранение
524 | firmware_check
525 | animation $! "Отключение системного DNS"
526 |
527 | # Завершение
528 | echo ""
529 | if [ "$PANEL" = "1" ]; then
530 | complete_info
531 | else
532 | complete_info_no_panel
533 | fi
534 | rm -- "$0"
535 |
536 | # Ждем Enter и ребутимся
537 | read -r
538 | reboot
539 |
--------------------------------------------------------------------------------
/Relic/webpanel/hpanel.tar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Ground-Zerro/HydraRoute/3b58d853c0d1d564a6ebc54d6b9069fd34754d24/Relic/webpanel/hpanel.tar
--------------------------------------------------------------------------------