Skip to content

Commit 30ce448

Browse files
committed
feat(decoy): harden decoy to mimic real static file server
- Replace nginx banner with Caddy to match Go crypto/tls fingerprint - Add per-install random template selection from busy templates - Stamp HTML bodies with per-node entropy to prevent fleet-wide detection - Implement proper static server mechanics: Content-Length, ETag, Last-Modified, conditional 304s, byte ranges, 405 for unsupported methods - Add jitter to recurring timers to eliminate network beacons - Skip redundant decoy handler rebuilds on config pushes
1 parent dcf1a87 commit 30ce448

15 files changed

Lines changed: 953 additions & 78 deletions

cmd/rospanel/service.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"github.com/AppsGanin/rospanel/internal/connguard"
2222
"github.com/AppsGanin/rospanel/internal/core"
2323
"github.com/AppsGanin/rospanel/internal/datasec"
24+
"github.com/AppsGanin/rospanel/internal/decoy"
2425
"github.com/AppsGanin/rospanel/internal/geo"
2526
"github.com/AppsGanin/rospanel/internal/hop"
2627
"github.com/AppsGanin/rospanel/internal/model"
@@ -457,6 +458,14 @@ func bootstrapPanel(st *store.Store) (string, error) {
457458
if err := st.SetSecretPath(secret); err != nil {
458459
return "", err
459460
}
461+
// First run, so nobody has chosen a decoy yet: pick one instead of leaving
462+
// every install on the same schema default. A shared front page makes the
463+
// whole fleet one search query, and the default was a "coming soon"
464+
// placeholder — a page that plausibly serves a few kilobytes a day, sitting
465+
// on a box that moves gigabytes. The operator can still change it.
466+
if err := st.SetDecoyTemplate(decoy.RandomTemplate()); err != nil {
467+
return "", err
468+
}
460469
}
461470

462471
if set.WSPath == "" {

internal/core/manager_nodes.go

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -520,11 +520,7 @@ func (m *Manager) CreateNode(name, host string) (*model.Node, error) {
520520
if err := m.EnsureNodeAPIPath(); err != nil {
521521
return nil, err
522522
}
523-
decoyTemplate, err := m.randomDecoy()
524-
if err != nil {
525-
return nil, err
526-
}
527-
n, err := m.store.CreateNode(name, host, decoyTemplate)
523+
n, err := m.store.CreateNode(name, host, m.randomDecoy())
528524
if errors.Is(err, store.ErrNodeNameTaken) {
529525
return nil, &ValidationError{Msg: "нода с таким названием уже есть — имя должно быть уникальным"}
530526
}
@@ -1252,15 +1248,15 @@ func (m *Manager) PurgeDeletedNodes() {
12521248
}
12531249
}
12541250

1255-
// randomDecoy picks a bundled decoy template at random so nodes don't all share
1256-
// the panel's masquerade fingerprint. Falls back to "" (agent default) on error.
1257-
func (m *Manager) randomDecoy() (string, error) {
1258-
list, err := decoy.Available()
1259-
if err != nil || len(list) == 0 {
1260-
return "", err
1261-
}
1262-
// Cheap, non-crypto pick: which masquerade a node wears isn't a secret.
1263-
return list[time.Now().UnixNano()%int64(len(list))], nil
1251+
// randomDecoy picks a decoy template for a new node so nodes don't all share the
1252+
// panel's masquerade fingerprint.
1253+
//
1254+
// Drawn from the busy-site pool rather than every bundled template: a node carries
1255+
// nothing BUT tunnelled traffic, so landing it on a placeholder or a "temporarily
1256+
// unavailable" page states outright that a box moving gigabytes is a site with
1257+
// nothing on it. The operator can still set any template afterwards.
1258+
func (m *Manager) randomDecoy() string {
1259+
return decoy.RandomTemplate()
12641260
}
12651261

12661262
// --- sync ingest --------------------------------------------------------------

internal/decoy/decoy.go

Lines changed: 183 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,45 @@
11
// Package decoy serves an innocent-looking website ("заглушка") for every
22
// request that doesn't carry the secret panel path. The goal: a visitor, DPI,
33
// or scanner sees an ordinary site, never a hint that a VPN panel exists.
4+
//
5+
// Looking ordinary is not only about the HTML. A probe compares what the server
6+
// DOES against what it claims to be, so the handler mimics a static file server
7+
// down to the mechanics: Content-Length on every response (net/http chunks bodies
8+
// over its 2 KB sniff buffer, and no static host chunks static files),
9+
// Last-Modified + ETag + conditional 304s, byte ranges, a 405 for methods a file
10+
// server does not implement, and a not-found page whose status agrees with its
11+
// body. See Handler.ServeHTTP.
412
package decoy
513

614
import (
15+
"bytes"
16+
"crypto/rand"
717
"embed"
818
"fmt"
919
"io/fs"
20+
"math/big"
1021
"mime"
1122
"net/http"
1223
"path"
24+
"strconv"
1325
"strings"
26+
"time"
1427
)
1528

1629
//go:embed all:templates
1730
var templatesFS embed.FS
1831

32+
// serverName is the Server header the decoy presents.
33+
//
34+
// It is deliberately NOT "nginx". Xray terminates TLS for :443 and falls back to
35+
// this handler, so everything an outside prober fingerprints at the TLS layer —
36+
// ClientHello response, extension order, session tickets, ALPN handling — is Go's
37+
// crypto/tls. An nginx banner over a Go TLS stack is a contradiction that costs
38+
// one JA4S lookup to spot. Caddy is Go, is a mainstream choice for exactly this
39+
// kind of static site, and matches the behaviour implemented below, so the banner
40+
// and the machine underneath tell the same story.
41+
const serverName = "Caddy"
42+
1943
// Available returns the list of bundled template slugs.
2044
func Available() ([]string, error) {
2145
entries, err := fs.ReadDir(templatesFS, "templates")
@@ -31,19 +55,60 @@ func Available() ([]string, error) {
3155
return out, nil
3256
}
3357

58+
// busyTemplates are the slugs picked from at install time. A decoy is not only
59+
// read by a scanner — it is also the cover story for the traffic volume the box
60+
// carries, and gigabytes a day flowing to an 8 KB "coming soon" placeholder is a
61+
// mismatch no amount of encryption hides. These templates are sites where large,
62+
// long-lived transfers are the whole point.
63+
//
64+
// The slugs are directory names, not brands: "YouTube" is a video-hosting layout
65+
// that presents itself as "Видеоландия" and carries no third-party naming.
66+
//
67+
// Left out are the templates that contradict a busy box rather than explain it:
68+
// the placeholders (coming-soon), the maintenance pages (503-*, maintenance) and
69+
// the bare nginx page. All stay selectable by hand — an operator who actually
70+
// wants a site that looks parked can still say so.
71+
var busyTemplates = []string{"filecloud", "downloader", "converter", "speedtest", "10gag", "YouTube"}
72+
73+
// RandomTemplate returns a template slug for a fresh install. Choosing at random
74+
// keeps a fleet from sharing one recognisable front page — the body stamp (see
75+
// Stamp) separates two installs that land on the same slug, and this separates
76+
// their look as well.
77+
func RandomTemplate() string {
78+
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(busyTemplates))))
79+
if err != nil {
80+
return busyTemplates[0]
81+
}
82+
return busyTemplates[n.Int64()]
83+
}
84+
85+
// asset is one preloaded file of a template, already stamped for this install.
86+
// Preloading trades ~1 MB (the largest template) for a request path that neither
87+
// allocates a copy of the file nor recomputes its validators.
88+
type asset struct {
89+
name string
90+
body []byte
91+
ct string
92+
etag string
93+
modTime time.Time
94+
}
95+
3496
// Handler serves a single decoy template.
3597
type Handler struct {
36-
fsys fs.FS
37-
status int // status for the HTML page: 200, or 503 for maintenance templates
98+
files map[string]*asset
99+
index *asset
100+
notFound *asset // the template's own 404.html, when it ships one
101+
down bool // maintenance template: every request answers 503 with the index page
38102
}
39103

40104
// maintenanceTemplates are slugs whose front page advertises the site as
41105
// temporarily unavailable; they should answer with 503, not 200, so the body and
42106
// status agree (a "503" page served as 200 is an easy tell).
43107
var maintenanceTemplates = map[string]bool{"503-1": true, "503-2": true, "maintenance": true}
44108

45-
// New returns a decoy handler for the given template slug.
46-
func New(template string) (*Handler, error) {
109+
// New returns a decoy handler for the given template slug, stamped with this
110+
// install's entropy (see LoadStamp).
111+
func New(template string, st Stamp) (*Handler, error) {
47112
if template == "" {
48113
template = "coming-soon"
49114
}
@@ -54,66 +119,138 @@ func New(template string) (*Handler, error) {
54119
if _, err := fs.Stat(sub, "index.html"); err != nil {
55120
return nil, fmt.Errorf("decoy template %q missing index.html: %w", template, err)
56121
}
57-
status := http.StatusOK
58-
if maintenanceTemplates[template] {
59-
status = http.StatusServiceUnavailable
122+
123+
h := &Handler{files: map[string]*asset{}, down: maintenanceTemplates[template]}
124+
err = fs.WalkDir(sub, ".", func(name string, d fs.DirEntry, err error) error {
125+
if err != nil || d.IsDir() {
126+
return err
127+
}
128+
body, err := fs.ReadFile(sub, name)
129+
if err != nil {
130+
return err
131+
}
132+
h.files[name] = newAsset(name, body, st)
133+
return nil
134+
})
135+
if err != nil {
136+
return nil, fmt.Errorf("decoy template %q: %w", template, err)
137+
}
138+
h.index = h.files["index.html"]
139+
h.notFound = h.files["404.html"]
140+
return h, nil
141+
}
142+
143+
// newAsset stamps one file and derives its validators. Only HTML carries the
144+
// per-install mark: binary assets have no place to put one, and their bytes are
145+
// not what a body-hash search matches on anyway.
146+
func newAsset(name string, body []byte, st Stamp) *asset {
147+
if strings.HasSuffix(name, ".html") {
148+
body = stampHTML(body, st.mark())
149+
}
150+
mod := st.modTime(name)
151+
return &asset{
152+
name: name,
153+
body: body,
154+
ct: contentType(name),
155+
modTime: mod,
156+
// Caddy's static ETag: base-36 modification time followed by the hex size.
157+
etag: fmt.Sprintf("%q", strconv.FormatInt(mod.Unix(), 36)+strconv.FormatInt(int64(len(body)), 16)),
60158
}
61-
return &Handler{fsys: sub, status: status}, nil
62159
}
63160

64-
// ServeHTTP serves the requested asset, falling back to the template's 404.html
65-
// (with a 404 status) for anything not found — so misses are indistinguishable
66-
// from a normal site's not-found page.
161+
// stampHTML inserts the per-install mark just before </body>, or appends it when
162+
// the document has no body tag.
163+
func stampHTML(body []byte, mark string) []byte {
164+
at := bytes.LastIndex(bytes.ToLower(body), []byte("</body>"))
165+
if at < 0 {
166+
return append(append(append([]byte{}, body...), '\n'), mark...)
167+
}
168+
out := make([]byte, 0, len(body)+len(mark)+1)
169+
out = append(out, body[:at]...)
170+
out = append(out, mark...)
171+
out = append(out, '\n')
172+
return append(out, body[at:]...)
173+
}
174+
175+
// ServeHTTP answers like a static file server: known paths are served with full
176+
// validators, unknown ones get the template's not-found behaviour, and methods a
177+
// file server doesn't implement get a 405.
67178
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
68-
// Look like a stock nginx box.
69-
w.Header().Set("Server", "nginx")
179+
w.Header().Set("Server", serverName)
180+
181+
// A maintenance decoy is down for everything, method included. The config it
182+
// imitates (nginx `return 503` for the whole server) answers every request the
183+
// same way, so gating methods first would have it refuse a POST with 405 while
184+
// its own page says the site is unavailable.
185+
if h.down {
186+
h.serve(w, r, h.index, http.StatusServiceUnavailable)
187+
return
188+
}
189+
190+
if r.Method != http.MethodGet && r.Method != http.MethodHead {
191+
// A static host has nothing to POST to, and OPTIONS isn't something a file
192+
// server implements either — nginx and Caddy both answer 405. Serving the full
193+
// front page under a 200 for every method, as this used to, is not something
194+
// either does.
195+
w.Header().Set("Allow", "GET, HEAD")
196+
w.Header().Set("Content-Length", "0")
197+
w.WriteHeader(http.StatusMethodNotAllowed)
198+
return
199+
}
70200

71201
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
72202
if name == "" {
73203
name = "index.html"
74204
}
75-
76-
body, err := fs.ReadFile(h.fsys, name)
77-
if err != nil {
78-
h.serveNotFound(w)
205+
a, ok := h.files[name]
206+
if !ok {
207+
h.serveMiss(w, r, name)
79208
return
80209
}
81-
w.Header().Set("Content-Type", contentType(name))
82-
w.WriteHeader(h.pageStatus(name))
83-
_, _ = w.Write(body)
210+
h.serve(w, r, a, http.StatusOK)
84211
}
85212

86-
// pageStatus returns the status for a served file: a maintenance/503 decoy returns
87-
// 503 for its HTML page (assets stay 200 so the page still renders styled); every
88-
// other template returns 200.
89-
func (h *Handler) pageStatus(name string) int {
90-
if h.status != http.StatusOK && strings.HasSuffix(name, ".html") {
91-
return h.status
213+
// serveMiss answers a path the template doesn't have.
214+
func (h *Handler) serveMiss(w http.ResponseWriter, r *http.Request, name string) {
215+
switch {
216+
// A template with its own 404 page is a classic static site: every miss is a 404
217+
// carrying that page.
218+
case h.notFound != nil:
219+
h.serve(w, r, h.notFound, http.StatusNotFound)
220+
221+
// The single-page templates ship no 404 page, and the hosting they imitate
222+
// (`try_files $uri /index.html`) answers an extensionless miss with the app
223+
// shell under a 200. Serving that same shell under a 404 — which is what
224+
// falling back to index used to do — is a contradiction no static host
225+
// produces, and one GET of a random path next to a GET of / exposes it.
226+
case path.Ext(name) == "":
227+
h.serve(w, r, h.index, http.StatusOK)
228+
229+
// A missing asset is a genuine 404, with the empty body such hosts return.
230+
default:
231+
w.Header().Set("Content-Length", "0")
232+
w.WriteHeader(http.StatusNotFound)
92233
}
93-
return http.StatusOK
94234
}
95235

96-
// serveNotFound answers an unknown path. A maintenance/503 decoy serves its
97-
// unavailable page (503); any other template serves its own 404.html, or falls
98-
// back to its index page — far less of a tell than a stock-nginx 404 body sitting
99-
// under a custom-looking site. The hardcoded nginx body is a last resort that
100-
// real templates (which all ship index.html) never reach.
101-
func (h *Handler) serveNotFound(w http.ResponseWriter) {
102-
status := http.StatusNotFound
103-
candidates := []string{"404.html", "index.html"}
104-
if h.status == http.StatusServiceUnavailable {
105-
status, candidates = http.StatusServiceUnavailable, []string{"index.html"}
106-
}
107-
w.Header().Set("Content-Type", "text/html; charset=utf-8")
108-
for _, name := range candidates {
109-
if body, err := fs.ReadFile(h.fsys, name); err == nil {
110-
w.WriteHeader(status)
111-
_, _ = w.Write(body)
112-
return
113-
}
236+
// serve writes one asset. The 200 path goes through http.ServeContent, which
237+
// supplies Content-Length, Last-Modified, Accept-Ranges, byte ranges and the
238+
// conditional 304s — the behaviour a static server has and a plain Write does
239+
// not. Other statuses are written directly (ServeContent always writes 200), with
240+
// Content-Length set so those bodies aren't chunked either.
241+
func (h *Handler) serve(w http.ResponseWriter, r *http.Request, a *asset, status int) {
242+
w.Header().Set("Content-Type", a.ct)
243+
w.Header().Set("Etag", a.etag)
244+
if status == http.StatusOK {
245+
http.ServeContent(w, r, a.name, a.modTime, bytes.NewReader(a.body))
246+
return
114247
}
248+
w.Header().Set("Last-Modified", a.modTime.UTC().Format(http.TimeFormat))
249+
w.Header().Set("Content-Length", strconv.Itoa(len(a.body)))
115250
w.WriteHeader(status)
116-
_, _ = w.Write([]byte("<html><head><title>404 Not Found</title></head><body><center><h1>404 Not Found</h1></center><hr><center>nginx</center></body></html>"))
251+
if r.Method != http.MethodHead {
252+
_, _ = w.Write(a.body)
253+
}
117254
}
118255

119256
func contentType(name string) string {

0 commit comments

Comments
 (0)