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.
412package decoy
513
614import (
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
1730var 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.
2044func 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.
3597type 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).
43107var 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.
67178func (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
119256func contentType (name string ) string {
0 commit comments