mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
用户指明的场景修正了我的一个实质错误。我之前写"VNC 不解决取凭据,凭据还在
浏览器 profile 里"——这对 cookie 类成立,但对**回环重定向**不成立:
浏览器在用户机器 → redirect 到 127.0.0.1:P → 打到**用户的**回环 → 服务端收不到 code
浏览器在服务端 → redirect 到 127.0.0.1:P → 打到**服务端的**回环 → 插件直接收到 code
后者不需要"提取凭据",code 自己流到监听方。所以对回环类登录,服务端浏览器
不是权宜之计而是**唯一自动化路径**。已实测验证(浏览器在服务端时 loopback
回调的 code 落到了服务端监听器)。
## 实现
plugin_browser.go:Xvfb(:N) ← chromium → x11vnc → websockify(noVNC) + 回环回调监听。
五个进程一起起停,因为失败模式是顺序相关的(崩掉的会话留下的 Xvfb 会让下一个
chromium 以看起来像浏览器 bug 的方式失败)。
**默认关闭,且不是"开了 VNC 就能登全部"**:每个平台单独走 vnc 流程才启动。
理由是这套东西风险最高——在服务端跑真实浏览器登录用户的第三方账号。
**边界**:插件拿到的是 view_url,**不是浏览器控制句柄**。CDP 端口不进 Lua。
否则一个插件就能读所有标签页的 cookie,而那个浏览器登录的是运维的账号。
**teardown 必须删 profile**:里面是活的第三方 session cookie,删它是安全契约
的一部分,不是收尾工作。
## viewer 反代(browser_proxy.go)
/browser/<id>/... → 该会话的 websockify。为什么不直连端口:
1. 鉴权——直连等于把已登录的浏览器暴露给任何能访问该端口的人(实测未鉴权
访问得 302 → /login,带 admin key 得 3912 字节 noVNC 页面)
2. WebSocket——noVNC 核心是 WS 升级,httputil.ReverseProxy 原生处理
3. 同源——WS 路径不需要跨域配置
## 内嵌在插件页签(用户要求)
viewer 用 iframe 内嵌进 channels 插件的登录区,而不是让人跳出去开新窗口:
- 插件 UI 拿到 view_url 就塞进 iframe src,登录完成/失效时清空 src 并隐藏
- 插件只对 `flow = "vnc"` 的平台起浏览器;其他流程继续用用户自己的浏览器
(等于白开一个高风险的浏览器是浪费)
## 插件本体:channels(同类插件)
单插件多平台,4 个平台示范四种流程(link-poll / callback / paste / vnc)。
登录 → 凭据进保险库(不进 state)→ add_source 接入内核 → 定时领额度。
声明式:plugin.kernel 申请内核动作、plugin.auth_hosts 申请登录主机,
设置页显示"申请中"由运维批准。平台表加一条就多一个平台,内核零改动。
开发中被测试抓到的三个真实缺陷(都在我自己的代码里):
1. handler 返回 `{url=…}` 没有 status/json → 内核**静默丢弃**,200 空响应、
无任何错误。已把所有 handler 统一为 {status,json},并加契约测试锁死。
2. 插件把 req.body 当表用 → 实际是 JSON **字符串**,所有平台参数都读不到。
加 bodyOf() 统一解码。
3. 顺带发现工作树里 discover.go 被改成 `return "image"`(非本次改动,像是早先
变异测试留下的),会改变模型类型推断;已还原。
测试:服务端浏览器会话(进程栈/默认拒绝/回调捕获/teardown 清理 profile)、
viewer 反代鉴权、插件 VNC 端到端、channels 插件端到端(含未授权时如实报告
source_error 而非假装成功)、handler 契约。
544 lines
15 KiB
Go
544 lines
15 KiB
Go
package lua
|
||
|
||
import (
|
||
"fmt"
|
||
"sort"
|
||
"strings"
|
||
|
||
golua "github.com/aarzilli/golua/lua"
|
||
)
|
||
|
||
// Package lua — Lua 绑定:凭据保险库 + 内核动作面
|
||
//
|
||
// 这两个全局把"插件自己主导链接认证"所需的两块补齐:
|
||
//
|
||
// __llmsproxy_secrets —— 安全存放 OAuth token / session / 设备指纹
|
||
// __llmsproxy_kernel —— 触发内核动作(注册源、探测、清冷却、改链路…)
|
||
//
|
||
// 注册点与 __llmsproxy_http 相同:只在 PLUGIN 的 bootPluginState 里装,
|
||
// 因为适配器共用 setupGlobals 而不该拿到这些能力。
|
||
//
|
||
// 两张表都用 buildJSONTable 的既有写法(NewTable + PushGoClosure + SetField),
|
||
// 该写法在 json 表上长期稳定,也是内核自己的用法。
|
||
|
||
// secretGlobal 是凭据保险库的 Lua 名字。
|
||
const secretGlobal = "__llmsproxy_secrets"
|
||
|
||
// kernelGlobal 是内核动作面的 Lua 名字。
|
||
const kernelGlobal = "__llmsproxy_kernel"
|
||
|
||
// registerSecretBridge installs the credential vault onto a plugin's state.
|
||
//
|
||
// The plugin name is bound HERE, at boot, rather than read from the Lua state at
|
||
// call time. That is what makes "a plugin can only read its OWN credentials" a
|
||
// mechanism instead of a convention: a shard or any other holder of the compiled
|
||
// script cannot pass a different name.
|
||
func registerSecretBridge(L *golua.State, ps *Plugins, name string) {
|
||
L.NewTable()
|
||
L.PushGoClosure(luaSecretSet(ps, name))
|
||
L.SetField(-2, "set")
|
||
L.PushGoClosure(luaSecretGet(ps, name))
|
||
L.SetField(-2, "get")
|
||
L.PushGoClosure(luaSecretDelete(ps, name))
|
||
L.SetField(-2, "delete")
|
||
L.PushGoClosure(luaSecretKeys(ps, name))
|
||
L.SetField(-2, "keys")
|
||
L.SetGlobal(secretGlobal)
|
||
}
|
||
|
||
// luaSecretSet: set(key, value) -> (true, nil) | (nil, err)
|
||
func luaSecretSet(ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
key := L.ToString(1)
|
||
val := L.ToString(2)
|
||
if err := ps.SetSecret(name, key, val); err != nil {
|
||
L.PushNil()
|
||
L.PushString(err.Error())
|
||
return 2
|
||
}
|
||
L.PushBoolean(true)
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
// luaSecretGet: get(key) -> (value, nil) | (nil, err). A missing key yields
|
||
// (nil, nil) so a plugin can test `if tok == nil then login() end` — the "no
|
||
// credential stored yet" case is the common one for a fresh install, and making
|
||
// it an error would force every caller to string-match on a message.
|
||
func luaSecretGet(ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
key := L.ToString(1)
|
||
v, ok, err := ps.GetSecret(name, key)
|
||
if err != nil {
|
||
L.PushNil()
|
||
L.PushString(err.Error())
|
||
return 2
|
||
}
|
||
if !ok {
|
||
L.PushNil()
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
L.PushString(v)
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
// luaSecretDelete: delete(key) -> (true, nil) | (nil, err)
|
||
func luaSecretDelete(ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
key := L.ToString(1)
|
||
if err := ps.DeleteSecret(name, key); err != nil {
|
||
L.PushNil()
|
||
L.PushString(err.Error())
|
||
return 2
|
||
}
|
||
L.PushBoolean(true)
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
// luaSecretKeys: keys() -> (array of key names, nil). Names only — a plugin can
|
||
// render "which accounts are configured" without ever echoing token bytes.
|
||
func luaSecretKeys(ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
keys, err := ps.SecretKeys(name)
|
||
if err != nil {
|
||
L.PushNil()
|
||
L.PushString(err.Error())
|
||
return 2
|
||
}
|
||
L.NewTable()
|
||
for i, k := range keys {
|
||
L.PushString(k)
|
||
L.RawSeti(-2, i+1)
|
||
}
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
// registerKernelBridge installs the kernel action surface onto a plugin's state.
|
||
func registerKernelBridge(L *golua.State, ps *Plugins, name string) {
|
||
L.NewTable()
|
||
L.PushGoClosure(luaKernelActions(ps, name))
|
||
L.SetField(-2, "actions")
|
||
L.PushGoClosure(luaKernelCall(ps, name))
|
||
L.SetField(-2, "call")
|
||
L.SetGlobal(kernelGlobal)
|
||
}
|
||
|
||
// luaKernelActions: actions() -> (array of {name,title,danger,description,params,
|
||
// granted}, nil)
|
||
//
|
||
// It reports GRANTED alongside each action so a plugin can degrade gracefully
|
||
// (skip the part that needs a permission it does not have) instead of
|
||
// discovering it by hitting an error mid-operation.
|
||
func luaKernelActions(ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
perm := ps.PluginKernelPermission(name)
|
||
L.NewTable()
|
||
for i, a := range KernelActions() {
|
||
L.NewTable()
|
||
L.PushString(a.Name)
|
||
L.SetField(-2, "name")
|
||
L.PushString(a.Title)
|
||
L.SetField(-2, "title")
|
||
L.PushString(a.Danger)
|
||
L.SetField(-2, "danger")
|
||
L.PushString(a.Description)
|
||
L.SetField(-2, "description")
|
||
L.PushBoolean(perm.allows(a))
|
||
L.SetField(-2, "granted")
|
||
L.NewTable()
|
||
for j, p := range a.Params {
|
||
L.PushString(p)
|
||
L.RawSeti(-2, j+1)
|
||
}
|
||
L.SetField(-2, "params")
|
||
L.RawSeti(-2, i+1)
|
||
}
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
// luaKernelCall: call(name, args) -> (result, nil) | (nil, err)
|
||
//
|
||
// One entry point rather than a function per action: the action set will change,
|
||
// and a table field per action would mean editing bootPluginState — the exact
|
||
// place that produced a cgo crash during earlier work.
|
||
func luaKernelCall(ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
action := L.ToString(1)
|
||
args := map[string]interface{}{}
|
||
if L.Type(2) == golua.LUA_TTABLE {
|
||
var raw interface{}
|
||
if err := luaToJSON(L, 2, &raw); err == nil {
|
||
if m, ok := raw.(map[string]interface{}); ok {
|
||
args = m
|
||
}
|
||
}
|
||
}
|
||
v, err := ps.callKernel(name, action, args)
|
||
if err != nil {
|
||
L.PushNil()
|
||
L.PushString(err.Error())
|
||
return 2
|
||
}
|
||
if v == nil {
|
||
L.PushNil()
|
||
} else {
|
||
pushGoValue(L, v)
|
||
}
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
// readPluginKernelDeclared reads `plugin.kernel`, the actions a plugin asks for.
|
||
//
|
||
// The shape is a list of names:
|
||
//
|
||
// plugin.kernel = { "status", "reset_health" }
|
||
//
|
||
// Unknown names are kept rather than dropped so the settings page can show a
|
||
// typo as "requested but not a real action" — silently discarding it would make
|
||
// a plugin's intent invisible at exactly the moment the operator is deciding
|
||
// what to grant.
|
||
func readPluginKernelDeclared(L *golua.State) []string {
|
||
raw, ok := pluginFieldValue(L, "kernel")
|
||
if !ok {
|
||
return nil
|
||
}
|
||
items, isList := raw.([]interface{})
|
||
if !isList {
|
||
if one, isTable := raw.(map[string]interface{}); isTable {
|
||
// A single string field or a set-style table; accept both spellings.
|
||
if v, ok := one["actions"].([]interface{}); ok {
|
||
items = v
|
||
} else {
|
||
return nil
|
||
}
|
||
} else {
|
||
return nil
|
||
}
|
||
}
|
||
out := make([]string, 0, len(items))
|
||
seen := map[string]bool{}
|
||
for _, it := range items {
|
||
name, ok := it.(string)
|
||
if !ok {
|
||
continue
|
||
}
|
||
name = strings.TrimSpace(name)
|
||
if name == "" || seen[name] {
|
||
continue
|
||
}
|
||
seen[name] = true
|
||
out = append(out, name)
|
||
}
|
||
sort.Strings(out)
|
||
return out
|
||
}
|
||
|
||
// loginGlobal is the Lua name of the login-session bridge.
|
||
const loginGlobal = "__llmsproxy_login"
|
||
|
||
// registerLoginBridge installs the login session API.
|
||
//
|
||
// A plugin drives the whole flow itself (it knows the platform's endpoints) and
|
||
// the kernel only owns the session lifecycle: create, poll, resolve, cancel.
|
||
// That split is what lets one kernel serve OAuth, device-code, QR and paste
|
||
// flows without containing any platform knowledge.
|
||
func registerLoginBridge(L *golua.State, ps *Plugins, name string) {
|
||
L.NewTable()
|
||
L.PushGoClosure(luaLoginBegin(ps, name))
|
||
L.SetField(-2, "begin")
|
||
L.PushGoClosure(luaLoginGet(ps, name))
|
||
L.SetField(-2, "get")
|
||
L.PushGoClosure(luaLoginResolve(ps, name))
|
||
L.SetField(-2, "resolve")
|
||
L.PushGoClosure(luaLoginList(ps, name))
|
||
L.SetField(-2, "list")
|
||
L.PushGoClosure(luaLoginCancel(ps, name))
|
||
L.SetField(-2, "cancel")
|
||
// 服务端浏览器(回环类登录的唯一自动化路径)。与登录会话同一张表,
|
||
// 因为它就是登录的一种流程,而不是独立能力。
|
||
L.PushGoClosure(luaBrowserStart(ps, name))
|
||
L.SetField(-2, "start_browser")
|
||
L.PushGoClosure(luaBrowserGet(ps, name))
|
||
L.SetField(-2, "get_browser")
|
||
L.PushGoClosure(luaBrowserStop(ps, name))
|
||
L.SetField(-2, "stop_browser")
|
||
L.SetGlobal(loginGlobal)
|
||
}
|
||
|
||
// luaBrowserStart: start_browser({platform, start_url, callback_port}) ->
|
||
//
|
||
// ({view_url, id, callback_port}, nil) | (nil, err)
|
||
//
|
||
// The plugin gets a VIEW URL, never a browser-control handle. That is the
|
||
// deliberate boundary: a plugin that could drive the browser arbitrarily could
|
||
// read every tab's cookies, and the browser is logged into the OPERATOR's
|
||
// third-party accounts, not the plugin's own.
|
||
func luaBrowserStart(ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
args, err := luaTableArg(L, 1)
|
||
if err != nil {
|
||
L.PushNil()
|
||
L.PushString("__llmsproxy_login.start_browser: " + err.Error())
|
||
return 2
|
||
}
|
||
port := 0
|
||
if n, ok := asFloat(args["callback_port"]); ok {
|
||
port = int(n)
|
||
}
|
||
sess, err := ps.StartBrowserSession(name, asString(args["platform"]),
|
||
asString(args["start_url"]), port)
|
||
if err != nil {
|
||
L.PushNil()
|
||
L.PushString(err.Error())
|
||
return 2
|
||
}
|
||
pushGoValue(L, browserToLua(*sess))
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
// luaBrowserGet: get_browser(id) -> (session, nil) | (nil, nil)
|
||
func luaBrowserGet(ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
id := L.ToString(1)
|
||
s, ok := ps.GetBrowserSession(id)
|
||
if !ok || s.Plugin != name {
|
||
L.PushNil()
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
pushGoValue(L, browserToLua(*s))
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
// luaBrowserStop: stop_browser(id) -> (true, nil)
|
||
func luaBrowserStop(ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
id := L.ToString(1)
|
||
if s, ok := ps.GetBrowserSession(id); ok && s.Plugin == name {
|
||
ps.StopBrowserSession(id)
|
||
}
|
||
L.PushBoolean(true)
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
// browserToLua renders a browser session for Lua. The CDP/debug ports are NOT
|
||
// included: the plugin receives what it needs to display the viewer and read the
|
||
// captured code, and nothing that would let it drive the browser.
|
||
func browserToLua(s BrowserSession) map[string]interface{} {
|
||
return map[string]interface{}{
|
||
"id": s.ID,
|
||
"platform": s.Platform,
|
||
"view_url": s.ViewURL,
|
||
"callback_port": s.CallbackPort,
|
||
"captured_code": s.CapturedCode,
|
||
"state": s.CapturedState,
|
||
"error": s.Error,
|
||
"started_at": s.StartedAt.Unix(),
|
||
"expires_at": s.ExpiresAt.Unix(),
|
||
}
|
||
}
|
||
|
||
// luaLoginBegin: begin({platform, flow, url, user_code}) -> (session, nil) | (nil, err)
|
||
func luaLoginBegin(ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
args, err := luaTableArg(L, 1)
|
||
if err != nil {
|
||
L.PushNil()
|
||
L.PushString("__llmsproxy_login.begin: " + err.Error())
|
||
return 2
|
||
}
|
||
s := LoginSession{
|
||
Plugin: name,
|
||
Platform: asString(args["platform"]),
|
||
Flow: LoginFlow(asString(args["flow"])),
|
||
URL: asString(args["url"]),
|
||
UserCode: asString(args["user_code"]),
|
||
}
|
||
out, err := ps.BeginLogin(s)
|
||
if err != nil {
|
||
L.PushNil()
|
||
L.PushString(err.Error())
|
||
return 2
|
||
}
|
||
pushGoValue(L, sessionToLua(*out, true))
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
// luaLoginGet: get(id) -> (session, nil) | (nil, err)
|
||
func luaLoginGet(ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
id := L.ToString(1)
|
||
s, ok := ps.GetLogin(id)
|
||
if !ok {
|
||
L.PushNil()
|
||
L.PushNil() // absent is not an error; the plugin tests for nil
|
||
return 2
|
||
}
|
||
if s.Plugin != name {
|
||
// Another plugin's session is indistinguishable from a missing one.
|
||
// Reporting "not yours" would confirm the id exists.
|
||
L.PushNil()
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
pushGoValue(L, sessionToLua(*s, false))
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
// luaLoginResolve: resolve(id, state, account, message) -> (true, nil) | (nil, err)
|
||
func luaLoginResolve(ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
id := L.ToString(1)
|
||
state := LoginSessionState(L.ToString(2))
|
||
account := L.ToString(3)
|
||
message := L.ToString(4)
|
||
s, ok := ps.GetLogin(id)
|
||
if !ok || s.Plugin != name {
|
||
L.PushNil()
|
||
L.PushString("no such login session")
|
||
return 2
|
||
}
|
||
if err := ps.ResolveLogin(id, state, account, message); err != nil {
|
||
L.PushNil()
|
||
L.PushString(err.Error())
|
||
return 2
|
||
}
|
||
L.PushBoolean(true)
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
// luaLoginList: list() -> (array of sessions, nil)
|
||
func luaLoginList(ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
sessions := ps.ListLogins(name)
|
||
L.NewTable()
|
||
for i, s := range sessions {
|
||
pushGoValue(L, sessionToLua(s, false))
|
||
L.RawSeti(-2, i+1)
|
||
}
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
// luaLoginCancel: cancel(id) -> (true, nil)
|
||
func luaLoginCancel(ps *Plugins, name string) golua.LuaGoFunction {
|
||
return func(L *golua.State) int {
|
||
id := L.ToString(1)
|
||
s, ok := ps.GetLogin(id)
|
||
if ok && s.Plugin == name {
|
||
_ = ps.ResolveLogin(id, LoginCancelled, "", "cancelled by the plugin")
|
||
}
|
||
L.PushBoolean(true)
|
||
L.PushNil()
|
||
return 2
|
||
}
|
||
}
|
||
|
||
// sessionToLua renders a session for Lua. The nonce is included ONLY on begin
|
||
// (the plugin needs it to build its redirect_uri); subsequent reads omit it so a
|
||
// plugin that logs or forwards a session cannot leak the CSRF token.
|
||
func sessionToLua(s LoginSession, includeNonce bool) map[string]interface{} {
|
||
out := map[string]interface{}{
|
||
"id": s.ID,
|
||
"platform": s.Platform,
|
||
"flow": string(s.Flow),
|
||
"state": string(s.State),
|
||
"url": s.URL,
|
||
"user_code": s.UserCode,
|
||
"message": s.Message,
|
||
"account": s.Account,
|
||
"created_at": s.CreatedAt.Unix(),
|
||
"expires_at": s.ExpiresAt.Unix(),
|
||
}
|
||
if includeNonce {
|
||
out["nonce"] = s.Nonce
|
||
}
|
||
return out
|
||
}
|
||
|
||
// luaTableArg decodes a Lua table argument into a Go map, reporting a clear error
|
||
// when the caller passed something else (a common mistake that would otherwise
|
||
// surface as a silent zero value).
|
||
func luaTableArg(L *golua.State, idx int) (map[string]interface{}, error) {
|
||
if L.Type(idx) != golua.LUA_TTABLE {
|
||
return nil, fmt.Errorf("expected a table argument, got %s", normalizeString(L, idx))
|
||
}
|
||
var raw interface{}
|
||
if err := luaToJSON(L, idx, &raw); err != nil {
|
||
return nil, err
|
||
}
|
||
m, ok := raw.(map[string]interface{})
|
||
if !ok {
|
||
return nil, fmt.Errorf("argument must be a keyed table")
|
||
}
|
||
return m, nil
|
||
}
|
||
|
||
// readPluginAuthHosts reads `plugin.auth_hosts`, the login hosts a plugin needs.
|
||
//
|
||
// plugin.auth_hosts = { "auth.example.com", "accounts.google.com" }
|
||
//
|
||
// A list of bare hostnames — not URLs — because the allowlist matches on host,
|
||
// and accepting full URLs would invite a plugin to smuggle a path or scheme that
|
||
// the operator did not read. Entries are normalised so the granted and declared
|
||
// forms always compare equal.
|
||
func readPluginAuthHosts(L *golua.State) []string {
|
||
raw, ok := pluginFieldValue(L, "auth_hosts")
|
||
if !ok {
|
||
return nil
|
||
}
|
||
items, isList := raw.([]interface{})
|
||
if !isList {
|
||
if one, isTable := raw.(map[string]interface{}); isTable {
|
||
if v, ok := one["hosts"].([]interface{}); ok {
|
||
items = v
|
||
} else {
|
||
return nil
|
||
}
|
||
} else {
|
||
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
|
||
}
|