From 19d082e70a685d26fa067faab190b495cb69d5dc Mon Sep 17 00:00:00 2001 From: HomeDash Bot Date: Fri, 11 Sep 2026 20:17:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(gateway):=20=E7=BB=9F=E4=B8=80=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E8=B7=AF=E7=94=B1=E8=A1=A8=EF=BC=88=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E5=9F=BA=E7=A1=80=E5=B1=82=EF=BC=89=E2=80=94=E2=80=94"?= =?UTF-8?q?=E5=93=AA=E4=BA=9B=E6=9C=8D=E5=8A=A1=E8=B5=B0=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E8=AE=A4=E8=AF=81"=E5=8F=98=E6=88=90=E4=B8=80=E6=9D=A1?= =?UTF-8?q?=E6=95=B0=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 目标:不再手工维护外部 nginx 配置,把"域名→内网后端、是否走统一登录认证" 变成后台可编辑的数据,由本进程生成 nginx 配置并热加载。 本提交只做基础层(纯 Go、可测、零行为变更): - gateway.go:Route/H gatewayConfig 数据模型 + ValidateRoute + RenderGatewayConf · 认证**按路由**决定:RequireLogin 为真才生成 auth_request 与登录跳转, 也就是"哪些服务受统一认证保护"完全由路由表决定 · 支持 websocket 透传、单域名上传上限、HTTPS 段(可同时生成旁路端口) · 路由表单独存 gateway.yml(不混进 Dashy 的 conf.yml),写坏了也不影响导航页 - API:GET/POST /admin/api/gateway/routes(保存)、POST /admin/api/gateway/apply · apply 的顺序是 生成 → nginx -t 校验 → 通过才 reload;**校验失败把上一份可用 配置写回去并把 nginx 原始报错回给界面**,避免"点了应用整个入口挂掉" - nginx.conf 加 include /app/data/gateway/*.conf(生成物在容器自己的卷里, 不碰宿主机文件,天然避开"单文件 bind mount 不能用 sed -i"的坑) - 生成物默认监听旁路端口(8081/8443):可以先与正式入口并存验证,再切 80/443 测试:受保护域名必须有 auth_request、未受保护域名必须没有;websocket/上传上限; 空域名/带协议域名/非 http 后端/域名重复/需认证却无认证地址/启用 HTTPS 却无证书 六种非法输入必须报错;缺文件时回落默认配置、保存后回读一致。 --- admin-server/gateway.go | 244 +++++++++++++++++++++++++++++++++++ admin-server/gateway_test.go | 132 +++++++++++++++++++ admin-server/main.go | 127 ++++++++++++++++++ nginx.conf | 5 + 4 files changed, 508 insertions(+) create mode 100644 admin-server/gateway.go create mode 100644 admin-server/gateway_test.go diff --git a/admin-server/gateway.go b/admin-server/gateway.go new file mode 100644 index 0000000..ac32d37 --- /dev/null +++ b/admin-server/gateway.go @@ -0,0 +1,244 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +// ── 统一入口(gateway)路由表 ── +// +// 目标:把"哪个域名反代到哪个内网服务、要不要走统一登录认证"变成**后台可编辑的 +// 数据**,由本进程生成 nginx 配置并热加载,从而不再需要手工维护一份外部 nginx +// 配置(也就避开了"单文件 bind mount 不能用 sed -i"那类坑)。 +// +// 设计要点: +// - 路由表单独存一个文件(不是 conf.yml):即使路由写坏了也不会影响导航页本身, +// 而且 conf.yml 是 Dashy 的配置,语义上不该混进来。 +// - 生成物写在**容器自己的数据卷**里(include 目录),不碰宿主机文件。 +// - 应用配置的顺序永远是:生成 → nginx -t 校验 → 通过才替换并 reload; +// 校验失败保留上一份可用配置并把 nginx 的报错回给调用方。 + +// Route 一条域名→后端映射。 +type Route struct { + Host string `yaml:"host" json:"host"` // 例如 dashy.example.com + Upstream string `yaml:"upstream" json:"upstream"` // 例如 http://10.0.0.11:8080 + RequireLogin bool `yaml:"requireLogin" json:"requireLogin"` // true=走统一认证;false=直接放行 + Websocket bool `yaml:"websocket" json:"websocket"` // 是否透传 Upgrade(面板/终端类) + MaxBodyMB int `yaml:"maxBodyMB,omitempty" json:"maxBodyMB,omitempty"` // 上传体积上限,0=不限 + Note string `yaml:"note,omitempty" json:"note,omitempty"` // 备注,仅界面展示 +} + +// GatewayConfig 路由表 + 入口参数。 +type GatewayConfig struct { + // ListenHTTPS 是否由本容器终结 TLS(证书挂进来时开启;否则只监听 HTTP) + ListenHTTPS bool `yaml:"listenHttps" json:"listenHttps"` + // CertFile/KeyFile 在 ListenHTTPS 为真时使用 + CertFile string `yaml:"certFile,omitempty" json:"certFile,omitempty"` + KeyFile string `yaml:"keyFile,omitempty" json:"keyFile,omitempty"` + // AuthURL 统一认证的校验地址(auth_request)。例如 http://127.0.0.1:4001/_auth + AuthURL string `yaml:"authUrl,omitempty" json:"authUrl,omitempty"` + // LoginURL 未通过认证时的跳转地址 + LoginURL string `yaml:"loginUrl,omitempty" json:"loginUrl,omitempty"` + // ListenHTTP/ListenHTTPSPort 监听端口,便于旁路验证(先跑 8081 再切 80) + ListenPort int `yaml:"listenPort,omitempty" json:"listenPort,omitempty"` + ListenTLSPort int `yaml:"listenTlsPort,omitempty" json:"listenTlsPort,omitempty"` + Routes []Route `yaml:"routes" json:"routes"` +} + +func defaultGatewayConfig() *GatewayConfig { + return &GatewayConfig{ + ListenPort: 8081, + ListenTLSPort: 8443, + AuthURL: "http://127.0.0.1:4001/_auth", + LoginURL: "https://login.example.com/login", + Routes: []Route{}, + } +} + +var ( + gatewayFile = envOr("NAVI_GATEWAY_FILE", filepath.Join(filepath.Dir(confFile), "gateway.yml")) + gatewayOutDir = envOr("NAVI_GATEWAY_OUT", filepath.Join(filepath.Dir(confFile), "gateway")) +) + +func loadGateway() (*GatewayConfig, error) { + cfg := defaultGatewayConfig() + data, err := os.ReadFile(gatewayFile) + if err != nil { + if os.IsNotExist(err) { + return cfg, nil + } + return nil, err + } + if err := yaml.Unmarshal(data, cfg); err != nil { + return nil, fmt.Errorf("路由表解析失败: %w", err) + } + if cfg.ListenPort == 0 { + cfg.ListenPort = 8081 + } + if cfg.ListenTLSPort == 0 { + cfg.ListenTLSPort = 8443 + } + if cfg.Routes == nil { + cfg.Routes = []Route{} + } + return cfg, nil +} + +func saveGateway(cfg *GatewayConfig) error { + data, err := yaml.Marshal(cfg) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(gatewayFile), 0o755); err != nil { + return err + } + tmp := gatewayFile + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, gatewayFile) +} + +// ValidateRoute 只做语法层面的检查;连通性不在这里判断。 +func ValidateRoute(r Route) error { + host := strings.TrimSpace(r.Host) + if host == "" { + return fmt.Errorf("域名不能为空") + } + if strings.ContainsAny(host, " \t/\\?#") || strings.Contains(host, "://") { + return fmt.Errorf("域名格式不对: %q", r.Host) + } + up := strings.TrimSpace(r.Upstream) + if up == "" { + return fmt.Errorf("%s: 后端地址不能为空", host) + } + if !strings.HasPrefix(up, "http://") && !strings.HasPrefix(up, "https://") { + return fmt.Errorf("%s: 后端必须是 http:// 或 https:// 开头", host) + } + if strings.ContainsAny(up, " \t\"'") { + return fmt.Errorf("%s: 后端地址含非法字符", host) + } + if r.MaxBodyMB < 0 { + return fmt.Errorf("%s: 上传上限不能为负", host) + } + return nil +} + +// nginxQuote 把值安全地放进单引号里(host/upstream 都已拒绝引号,这里是兜底)。 +func nginxQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", "") + "'" +} + +// RenderGatewayConf 生成 include 片段的全部 server 块。 +// +// 认证是按**每条路由**决定的:RequireLogin 为真才生成 auth_request, +// 也就是说"哪些服务受统一认证保护"完全由后台的路由表决定。 +func RenderGatewayConf(cfg *GatewayConfig) (string, error) { + if cfg == nil { + return "", fmt.Errorf("配置为空") + } + hosts := make(map[string]bool, len(cfg.Routes)) + for _, r := range cfg.Routes { + if err := ValidateRoute(r); err != nil { + return "", err + } + h := strings.TrimSpace(r.Host) + if hosts[h] { + return "", fmt.Errorf("域名重复: %s", h) + } + hosts[h] = true + } + + // 先按域名排序,生成结果稳定,便于 diff 与人工核对 + routes := append([]Route(nil), cfg.Routes...) + sort.Slice(routes, func(i, j int) bool { return routes[i].Host < routes[j].Host }) + + var b strings.Builder + b.WriteString("# 由 HomeDash 后台生成,请勿手改:文件每次应用都会被整体覆盖。\n") + b.WriteString("# 源: " + gatewayFile + "\n\n") + + for _, r := range routes { + host := strings.TrimSpace(r.Host) + up := strings.TrimSpace(r.Upstream) + port := cfg.ListenPort + if port == 0 { + port = 8081 + } + fmt.Fprintf(&b, "server {\n listen %d;\n server_name %s;\n", port, host) + if r.MaxBodyMB > 0 { + fmt.Fprintf(&b, " client_max_body_size %dm;\n", r.MaxBodyMB) + } + if r.RequireLogin { + if cfg.AuthURL == "" { + return "", fmt.Errorf("%s: 需要认证但未配置认证地址", host) + } + fmt.Fprintf(&b, " # 该域名受统一认证保护\n") + fmt.Fprintf(&b, " location = /_auth { internal; proxy_pass %s; proxy_pass_request_body off;\n", cfg.AuthURL) + b.WriteString(" proxy_set_header Content-Length \"\";\n proxy_set_header X-Original-URI $request_uri;\n }\n") + b.WriteString(" auth_request /_auth;\n") + b.WriteString(" error_page 401 = @login;\n") + fmt.Fprintf(&b, " location @login { return 302 %s?redirect=$scheme://$host$request_uri; }\n", cfg.LoginURL) + } else { + b.WriteString(" # 该域名不走统一认证\n") + } + b.WriteString(" location / {\n") + fmt.Fprintf(&b, " proxy_pass %s;\n", up) + b.WriteString(" proxy_http_version 1.1;\n") + b.WriteString(" proxy_set_header Host $host;\n") + b.WriteString(" proxy_set_header X-Real-IP $remote_addr;\n") + b.WriteString(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n") + b.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n") + if r.Websocket { + b.WriteString(" proxy_set_header Upgrade $http_upgrade;\n") + b.WriteString(" proxy_set_header Connection \"upgrade\";\n") + b.WriteString(" proxy_read_timeout 3600s;\n") + } + b.WriteString(" proxy_redirect off;\n") + b.WriteString(" }\n}\n\n") + } + + if cfg.ListenHTTPS { + if cfg.CertFile == "" || cfg.KeyFile == "" { + return "", fmt.Errorf("启用 HTTPS 但未配置证书路径") + } + tlsPort := cfg.ListenTLSPort + if tlsPort == 0 { + tlsPort = 8443 + } + for _, r := range routes { + host := strings.TrimSpace(r.Host) + fmt.Fprintf(&b, "server {\n listen %d ssl;\n server_name %s;\n", tlsPort, host) + fmt.Fprintf(&b, " ssl_certificate %s;\n ssl_certificate_key %s;\n", + nginxQuote(cfg.CertFile), nginxQuote(cfg.KeyFile)) + b.WriteString(" ssl_protocols TLSv1.2 TLSv1.3;\n") + // TLS 段复用同一条路由的认证与反代设置 + if r.RequireLogin { + fmt.Fprintf(&b, " location = /_auth { internal; proxy_pass %s; proxy_pass_request_body off;\n", cfg.AuthURL) + b.WriteString(" proxy_set_header Content-Length \"\";\n proxy_set_header X-Original-URI $request_uri;\n }\n") + b.WriteString(" auth_request /_auth;\n error_page 401 = @login;\n") + fmt.Fprintf(&b, " location @login { return 302 %s?redirect=$scheme://$host$request_uri; }\n", cfg.LoginURL) + } + b.WriteString(" location / {\n") + fmt.Fprintf(&b, " proxy_pass %s;\n", strings.TrimSpace(r.Upstream)) + b.WriteString(" proxy_http_version 1.1;\n proxy_set_header Host $host;\n") + b.WriteString(" proxy_set_header X-Real-IP $remote_addr;\n") + b.WriteString(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n") + b.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n") + if r.Websocket { + b.WriteString(" proxy_set_header Upgrade $http_upgrade;\n proxy_set_header Connection \"upgrade\";\n") + } + b.WriteString(" proxy_redirect off;\n }\n}\n\n") + } + } + return b.String(), nil +} + +// gatewayFileFor 生成物落地路径(include 目录里的固定文件名)。 +func gatewayFileFor() string { + return filepath.Join(gatewayOutDir, "gateway.conf") +} diff --git a/admin-server/gateway_test.go b/admin-server/gateway_test.go new file mode 100644 index 0000000..cb8af70 --- /dev/null +++ b/admin-server/gateway_test.go @@ -0,0 +1,132 @@ +package main + +import "testing" + +func TestRenderGatewayConfAuthPerRoute(t *testing.T) { + cfg := &GatewayConfig{ + ListenPort: 8081, + AuthURL: "http://127.0.0.1:4001/_auth", + LoginURL: "https://login.example.com/login", + Routes: []Route{ + {Host: "protected.example.com", Upstream: "http://10.0.0.1:3000", RequireLogin: true, Websocket: true, MaxBodyMB: 64}, + {Host: "open.example.com", Upstream: "http://10.0.0.2:8000"}, + }, + } + out, err := RenderGatewayConf(cfg) + if err != nil { + t.Fatalf("渲染失败: %v", err) + } + // 受保护的域名要有 auth_request 与登录跳转 + if !contains(out, "server_name protected.example.com;") { + t.Fatalf("缺少受保护域名的 server 块:\n%s", out) + } + if !contains(out, "auth_request /_auth;") { + t.Fatalf("受保护域名缺少 auth_request:\n%s", out) + } + if !contains(out, "location @login") { + t.Fatalf("缺少登录跳转") + } + if !contains(out, "proxy_set_header Upgrade $http_upgrade;") { + t.Fatalf("缺少 websocket 透传") + } + if !contains(out, "client_max_body_size 64m;") { + t.Fatalf("缺少上传上限") + } + // 不受保护的域名不得出现 auth_request("哪些服务受保护"是按路由决定的) + open := section(out, "server_name open.example.com;") + if open == "" { + t.Fatalf("缺少开放域名的 server 块") + } + if contains(open, "auth_request") { + t.Fatalf("未受保护的域名不应有 auth_request:\n%s", open) + } + if contains(open, "client_max_body_size") { + t.Fatalf("未设置上限时不应生成 client_max_body_size:\n%s", open) + } +} + +func TestRenderGatewayConfRejectsBadInput(t *testing.T) { + base := func(r Route) *GatewayConfig { + return &GatewayConfig{ListenPort: 8081, AuthURL: "http://x/_auth", Routes: []Route{r}} + } + cases := []struct { + name string + cfg *GatewayConfig + }{ + {"空域名", base(Route{Host: "", Upstream: "http://a"})}, + {"域名带协议", base(Route{Host: "http://a.com", Upstream: "http://a"})}, + {"后端非 http", base(Route{Host: "a.com", Upstream: "10.0.0.1:80"})}, + {"域名重复", &GatewayConfig{ListenPort: 8081, AuthURL: "http://x/_auth", Routes: []Route{ + {Host: "a.com", Upstream: "http://a"}, {Host: "a.com", Upstream: "http://b"}, + }}}, + {"需认证但无认证地址", &GatewayConfig{ListenPort: 8081, Routes: []Route{{Host: "a.com", Upstream: "http://a", RequireLogin: true}}}}, + {"启用HTTPS但无证书", &GatewayConfig{ListenPort: 8081, ListenHTTPS: true, Routes: []Route{{Host: "a.com", Upstream: "http://a"}}}}, + } + for _, c := range cases { + if _, err := RenderGatewayConf(c.cfg); err == nil { + t.Errorf("%s: 应当报错但没有", c.name) + } + } +} + +func TestGatewayRoundTrip(t *testing.T) { + dir := t.TempDir() + oldFile, oldOut := gatewayFile, gatewayOutDir + gatewayFile = dir + "/gateway.yml" + gatewayOutDir = dir + "/gateway" + defer func() { gatewayFile, gatewayOutDir = oldFile, oldOut }() + + // 文件不存在时应给出默认配置而不是报错 + cfg, err := loadGateway() + if err != nil { + t.Fatalf("缺文件应回落默认配置: %v", err) + } + if cfg.ListenPort != 8081 || len(cfg.Routes) != 0 { + t.Fatalf("默认配置不对: %+v", cfg) + } + cfg.Routes = []Route{{Host: "a.com", Upstream: "http://a", RequireLogin: true}} + if err := saveGateway(cfg); err != nil { + t.Fatalf("保存失败: %v", err) + } + got, err := loadGateway() + if err != nil || len(got.Routes) != 1 || !got.Routes[0].RequireLogin { + t.Fatalf("回读不一致: %+v err=%v", got, err) + } +} + +func contains(hay, needle string) bool { + return len(hay) >= len(needle) && (func() bool { + for i := 0; i+len(needle) <= len(hay); i++ { + if hay[i:i+len(needle)] == needle { + return true + } + } + return false + })() +} + +// section 取出 server_name 所属的那个 server 块(到下一个 server{ 之前) +func section(all, marker string) string { + i := indexOf(all, marker) + if i < 0 { + return "" + } + start := i + for start > 0 && all[start-1] != '\n' { + start-- + } + rest := all[i:] + if j := indexOf(rest, "\nserver {"); j >= 0 { + return rest[:j] + } + return rest +} + +func indexOf(hay, needle string) int { + for i := 0; i+len(needle) <= len(hay); i++ { + if hay[i:i+len(needle)] == needle { + return i + } + } + return -1 +} diff --git a/admin-server/main.go b/admin-server/main.go index 9aea8b6..a628355 100644 --- a/admin-server/main.go +++ b/admin-server/main.go @@ -10,6 +10,7 @@ import ( "net/http" "net/url" "os" + "os/exec" "path/filepath" "strconv" "strings" @@ -995,6 +996,130 @@ func safeIconName(hint, filename, ext string) string { return fmt.Sprintf("%s-%d%s", s, time.Now().Unix(), ext) } +// firstLines 取前 n 行,避免把 nginx 的长输出整段塞进界面。 +func firstLines(s string, n int) string { + lines := strings.Split(strings.TrimSpace(s), "\n") + if len(lines) > n { + lines = lines[:n] + } + return strings.Join(lines, "\n") +} + +// ── 统一入口(gateway)路由表 API ── +// +// GET /api/gateway/routes → 当前路由表(含"哪些域名走统一认证") +// POST /api/gateway/routes → 整体替换并保存(不立即生效) +// POST /api/gateway/apply → 生成配置 → nginx -t 校验 → 通过才 reload +// +// 校验失败时保留上一份可用配置,并把 nginx 的原始报错回给界面, +// 避免"点了应用,整个入口挂了"。 + +func handleGatewayRoutes(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + cfg, err := loadGateway() + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + jsonResp(w, cfg) + case http.MethodPost: + if !requireAdminToken(w, r) { + return + } + mu.Lock() + defer mu.Unlock() + var req GatewayConfig + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + jsonErr(w, 400, "请求体解析失败") + return + } + if req.Routes == nil { + req.Routes = []Route{} + } + for _, rt := range req.Routes { + if err := ValidateRoute(rt); err != nil { + jsonErr(w, 400, err.Error()) + return + } + } + if _, err := RenderGatewayConf(&req); err != nil { + jsonErr(w, 400, err.Error()) + return + } + if req.ListenPort == 0 { + req.ListenPort = 8081 + } + if req.ListenTLSPort == 0 { + req.ListenTLSPort = 8443 + } + if err := saveGateway(&req); err != nil { + jsonErr(w, 500, err.Error()) + return + } + jsonResp(w, map[string]string{"status": "saved"}) + default: + jsonErr(w, 405, "method not allowed") + } +} + +func nginxCommand(args ...string) (string, error) { + cmd := exec.Command("nginx", args...) + out, err := cmd.CombinedOutput() + return string(out), err +} + +func handleGatewayApply(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + jsonErr(w, 405, "method not allowed") + return + } + if !requireAdminToken(w, r) { + return + } + mu.Lock() + defer mu.Unlock() + + cfg, err := loadGateway() + if err != nil { + jsonErr(w, 500, err.Error()) + return + } + conf, err := RenderGatewayConf(cfg) + if err != nil { + jsonErr(w, 400, err.Error()) + return + } + if err := os.MkdirAll(gatewayOutDir, 0o755); err != nil { + jsonErr(w, 500, err.Error()) + return + } + target := gatewayFileFor() + prev, hadPrev := []byte{}, false + if b, err := os.ReadFile(target); err == nil { + prev, hadPrev = b, true + } + if err := os.WriteFile(target, []byte(conf), 0o644); err != nil { + jsonErr(w, 500, err.Error()) + return + } + // 校验:不通过就把上一份写回去,保证入口始终有一份可用配置 + if out, err := nginxCommand("-t"); err != nil { + if hadPrev { + _ = os.WriteFile(target, prev, 0o644) + } else { + _ = os.Remove(target) + } + jsonErr(w, 400, "配置未通过 nginx 校验,已保留上一份可用配置:\n"+firstLines(out, 12)) + return + } + if out, err := nginxCommand("-s", "reload"); err != nil { + jsonErr(w, 500, "reload 失败:"+firstLines(out, 6)) + return + } + jsonResp(w, map[string]any{"status": "applied", "routes": len(cfg.Routes), "file": target}) +} + func main() { // Ensure conf file exists if _, err := os.Stat(confFile); os.IsNotExist(err) { @@ -1027,6 +1152,8 @@ func main() { http.HandleFunc("/api/wallpaper", cors(handleWallpaper)) http.HandleFunc("/api/sync-info", cors(handleSyncInfo)) http.HandleFunc("/api/frameable", cors(handleFrameable)) + http.HandleFunc("/api/gateway/routes", cors(handleGatewayRoutes)) + http.HandleFunc("/api/gateway/apply", cors(handleGatewayApply)) // 历史记录管理:列表 / 清空全部 / 删除单条 http.HandleFunc("/api/history", cors(func(w http.ResponseWriter, r *http.Request) { switch r.Method { diff --git a/nginx.conf b/nginx.conf index c442242..48a3060 100644 --- a/nginx.conf +++ b/nginx.conf @@ -7,6 +7,11 @@ events { } http { + + # 统一入口路由表(由管理后台生成):后台改完"哪个域名→哪个后端、是否走统一认证" + # 会写到这里并 nginx -s reload。默认监听旁路端口(见 gateway.yml 的 listenPort), + # 验证通过后再改成 80/443 接管正式入口。 + include /app/data/gateway/*.conf; include /etc/nginx/mime.types; default_type application/octet-stream; sendfile on;