package lua import ( "fmt" "sort" "strings" golua "github.com/aarzilli/golua/lua" ) // Package lua — 插件额外出站主机的声明与批准 // // 内核的职责里有一条是"维护源"。源的 host 天然进出站白名单。但有些插件需要去 // **源以外**的 host(比如一个账号平台的门户、领取额度的接口),这些 host 默认 // 被白名单拒绝 —— 这是正确的默认:白名单的意义就是"插件只能去它该去的地方"。 // // 所以给出一个**通用**机制(不含任何"登录"概念——登录是插件自己的事): // // 插件在源码里声明 plugin.hosts = { "portal.example.com", ... } ← 请求 // 运维在设置页逐个批准 ← 授权 // // 声明是请求,批准才是授权。为什么不给个"允许任意 host"的开关:一个能访问任意 // host 的插件等于没有白名单。要求逐个列出,运维才有机会看出"这个插件为什么要 // 访问一个我没听说过的域名"。 // // 内核在这里**只做白名单加减**,不认识这些 host 是用来登录、领额度还是别的 —— // 那是插件的业务。 // PluginExtraHosts returns the extra outbound hosts a plugin declared. func (ps *Plugins) PluginExtraHosts(plugin string) []string { ps.mu.RLock() defer ps.mu.RUnlock() for _, p := range ps.plugins { if p.Info.Name == plugin { return append([]string(nil), p.ExtraHosts...) } } return nil } // GrantExtraHosts approves a set of outbound hosts for a plugin. // // Only hosts the plugin DECLARED may be granted: accepting an arbitrary host from // the settings page would make the declaration decorative, and the point of the // declaration is that the operator reviews a list the plugin itself committed to // in source that is on disk and reviewable. func (ps *Plugins) GrantExtraHosts(plugin string, hosts []string) error { if ps.find(plugin) == nil { return fmt.Errorf("plugin %s not found", plugin) } declared := map[string]bool{} for _, h := range ps.PluginExtraHosts(plugin) { declared[normalizeHost(h)] = true } clean := make([]string, 0, len(hosts)) for _, h := range hosts { hn := normalizeHost(h) if hn == "" { continue } if !declared[hn] { return fmt.Errorf("plugin %s did not declare outbound host %q", plugin, hn) } clean = append(clean, hn) } sort.Strings(clean) ps.mu.Lock() if ps.extraHosts == nil { ps.extraHosts = map[string][]string{} } if len(clean) == 0 { delete(ps.extraHosts, plugin) } else { ps.extraHosts[plugin] = clean } ps.rebuildOutboundAllowlistLocked() ps.saveKernelPermissionsLocked() ps.mu.Unlock() return nil } // ExtraHostsGranted returns the approved hosts for each plugin (for the UI). func (ps *Plugins) ExtraHostsGranted() map[string][]string { ps.mu.RLock() defer ps.mu.RUnlock() out := make(map[string][]string, len(ps.extraHosts)) for k, v := range ps.extraHosts { out[k] = append([]string(nil), v...) } return out } // normalizeHost lowercases a host and strips a port, so "Portal.Example.com:443" // and "portal.example.com" cannot both be granted under different names. func normalizeHost(h string) string { h = strings.ToLower(strings.TrimSpace(h)) if i := strings.LastIndex(h, ":"); i > 0 && !strings.Contains(h[i:], "]") { // Leave IPv6 literals ("[::1]:443") alone apart from the port. h = h[:i] } h = strings.Trim(h, "[]") return h } // rebuildOutboundAllowlistLocked folds the SOURCE hosts and every GRANTED extra // host into the allowlist the HTTP bridge consults. Caller holds ps.mu. // // Declared-but-not-granted hosts are deliberately excluded: that exclusion is the // whole security property, so it is the one thing this function must not be // tempted to "helpfully" relax. func (ps *Plugins) rebuildOutboundAllowlistLocked() { hosts := append([]string(nil), ps.sourceHosts...) for _, granted := range ps.extraHosts { hosts = append(hosts, granted...) } ps.allowHTTP = newHTTPAllowlist(hosts) } // readPluginExtraHosts reads `plugin.hosts`, the outbound hosts a plugin needs // beyond the operator-configured sources. // // plugin.hosts = { "portal.example.com", "api.partner.example" } // // Bare hostnames, not URLs: the allowlist matches on host, and accepting a full // URL would invite a plugin to smuggle a path or scheme the operator did not // read. Entries are normalised so declared and granted forms compare equal. func readPluginExtraHosts(L *golua.State) []string { raw, ok := pluginFieldValue(L, "hosts") if !ok { return nil } items, isList := raw.([]interface{}) if !isList { return nil } out := make([]string, 0, len(items)) seen := map[string]bool{} for _, it := range items { s, ok := it.(string) if !ok { continue } h := normalizeHost(s) if h == "" || seen[h] { continue } seen[h] = true out = append(out, h) } sort.Strings(out) return out }