# 用 Ghostunnel 和 SPIRE 为 NGINX 提供 SPIFFE 认证

之前对 SPIFFE 和 SPIRE 进行了一个相对全面/啰嗦的介绍，这一篇就反过来，用一个简单的例子来展示 SPIRE 的基本用法，本文中会以 NGINX 作为服务生产方，使用 [Ghostunnel](https://github.com/ghostunnel/ghostunnel) 当做 NGINX 的反向代理，把原有的 HTTP 通信升级为支持定期正顺轮转的双向 TLS 认证协议，并且用 CURL 使用客户端证书来通过 Ghostunnel 安全地访问背后的 NGINX。这里为 CURL 和 NGINX 提供证书以及轮转的，就是 SPIRE 的 Server 和 Agent。

Ghostunnel 是一个简单的 TLS 代理，能为非 TLS 的后端提供双向认证能力。Ghostunnel 能够以服务端（反向代理）或者客户端（代理）的模式进行工作，类似 stunnel。不同的是，他还支持访问控制、证书轮转、ACME 以及最近总在唠叨的 SPIFFE。

本文中会演示的过程实际上是 Ghostunnel 的 [SPIFFE DEMO](https://github.com/ghostunnel/ghostunnel/tree/master/docs/spiffe-workload-api-demo) 的一个精简版，会略细致讲述每个步骤涉及的内容。整个过程分为如下一些环节：

1. 环境准备：准备运行环境，包括 SPIRE Agent/Server 的构建、NGINX 的安装、以及 Ghostunnel 的构建等
    
2. 编写 SPIRE Server 配置，并启动
    
3. 生成 Ghostunnel 以及 CURL 的 Agent Token，并编写配置文件启动对应的 SPIRE Agent
    
4. 启动 Ghostunnel
    
5. 获取 CURL 客户端证书并测试连接
    

## 环境准备

这里使用的是基于 ARM 的一个 Ubuntu 系统，使用 APT 安装并启动 NGINX。另外后续步骤还需要 GIT 工具以及连接 GITHUB，并使用 GOLANG 构建 SPIRE 以及 Ghostunnel。

GIT 获取 SPIRE 版本，并进行构建：

```plaintext
$ git clone --single-branch --branch v1.4.0 https://github.com/spiffe/spire.git
Cloning into 'spire'...
...
$ cd spire
$ make bin/spire-agent
Installing go1.18.4...
Building bin/spire-agent...
$ make bin/spire-server
Building bin/spire-server...
```

接下来获取 Ghostunnel 并进行构建：

```plaintext
$ git clone https://github.com/ghostunnel/ghostunnel.git
Cloning into 'ghostunnel'...
...
$ make ghostunnel
go build -ldflags '-X main.version=v1.6.1-25-g8ae18ea' -o ghostunnel .
...
```

构建成功后，把三个新生成的可执行文件拷贝到可见目录备用。

然后建立测试目录，大致目录结构如下：

* spire-101
    
    * certs
        
    * conf
        
    * data
        
    * logs
        
    * socks
        

## 编写 SPIRE Server 配置并启动服务

```plaintext
server {
    bind_address = "0.0.0.0"
    bind_port = "8081"
    socket_path = "socks/spire-server.sock"
    trust_domain = "spiffe.dom"
    data_dir = "data/spire-server"
    log_level = "DEBUG"
    ca_ttl = "30m"
    default_svid_ttl = "2m"
    ca_subject = {
        country = ["CN"],
        organization = ["FUNNY"],
        common_name = "",
    }
}

plugins {
    DataStore "sql" {
        plugin_data {
            database_type = "sqlite3"
            connection_string = "data/spire-server/datastore.sqlite3"
        }
    }

    NodeAttestor "join_token" {
        plugin_data {
        }
    }

    KeyManager "disk" {
        plugin_data {
            keys_path = "data/spire-server/keys.json"
        }
    }
}
```

此处配置文件的几个要点：

* TCP 监听 `0.0.0.0:8081`
    
* 监听 Socket 路径为 `socks/spire-server.sock`
    
* 使用 `spiffe.dom` 作为信任域
    
* SVID 的默认寿命为 2 分钟
    
* 使用 SQLite3 作为数据存储引擎，数据库文件保存在 `data/spire-server/datastore.sqlite3`
    
* 在本地存储 Key，路径为 `data/spire-server/keys.json`。
    

然后用这个配置文件启动 SPIRE Server：`spire-server run -config conf/spire-server.conf > logs/spire-server.log 2>&1 &`

## 启动 Agent

这个小实验需要用到两个 Agent，分别负责服务端和客户端的身份。在运行 Agent 之前，首先要获取 SPIRE Server 的 Trust Bundle：

```plaintext
$ spire-server bundle show \
  -socketPath socks/spire-server.sock > conf/bundle.crt
```

上述命令将 Trunst Bundle 保存到文件 `conf/bundle.crt`。

服务端 Agent 配置文件如下：

```plaintext
agent {
    data_dir = "data/server-side-agent"
    log_level = "DEBUG"
    server_address = "127.0.0.1"
    server_port = "8081"
    socket_path ="socks/server-side-agent.sock"
    trust_bundle_path = "conf/bundle.crt"
    trust_domain = "spiffe.dom"
}

plugins {
    NodeAttestor "join_token" {
        plugin_data {
        }
    }
    KeyManager "disk" {
        plugin_data {
            directory = "data/server-side-agent"
        }
   }
   WorkloadAttestor "unix" {
        plugin_data {
             discover_workload_path = true
        }
    }
}
```

这个配置的要点是：

* 使用 `127.0.0.1:8081` 作为 SPIRE Server
    
* 监听 `socks/spire-server.sock`
    
* 信任 `conf/bundle.crt`
    
* Unix Workload Attestor 中开放了选项 `discover_workload_path`，从而可以通过二进制文件位置或者哈希识别调用 Agent 的应用的身份
    

为这个 Agent 创建一个 Token，用于标识 Agent 的身份：

```plaintext
$ spire-server token generate \
    -socketPath socks/spire-server.sock \
    -spiffeID spiffe://spiffe.dom/server-node
Token: [Token Hash]
```

上面命令生成了一个 Token，其 SPIFFE ID 为 `spiffe://spiffe.dom/server-node`。然后启动服务侧 Agent：

```plaintext
$ spire-agent run \
    -config conf/client-side-agent.conf \
    -joinToken [Token Hash] > logs/client-side-agent.log 2>&1 &
```

接下来启动客户侧的 Agent，配置文件如下：

```plaintext
agent {
    data_dir = "data/client-side-agent"
    log_level = "DEBUG"
    server_address = "127.0.0.1"
    server_port = "8081"
    socket_path ="socks/client-side-agent.sock"
    trust_bundle_path = "conf/bundle.crt"
    trust_domain = "spiffe.dom"
}

plugins {
    NodeAttestor "join_token" {
        plugin_data {
        }
    }
    KeyManager "disk" {
        plugin_data {
            directory = "data/client-side-agent"
        }
   }
   WorkloadAttestor "unix" {
        plugin_data {
        }
    }
}
```

跟上面的类似，我们也需要创建 Token 之后才能启动 Agent：

```plaintext
$ spire-server token generate \
    -socketPath socks/spire-server.sock \
    -spiffeID spiffe://spiffe.dom/client-node
Token: [Token Hash]
```

使用上述 Token 和 配置文件启动 Agent：

```plaintext
$ spire-agent run \
    -config conf/client-side-agent.conf \
    -joinToken "$TOKEN" > logs/client-side-agent.log 2>&1 &
```

## 启动 Ghostunnel

首先要给 Ghostunnel 一个身份，也就是 Entry：

```plaintext
$ spire-server entry create \
    -selector unix:path:/usr/local/bin/ghostunnel \
    -socketPath socks/spire-server.sock \
    -spiffeID spiffe://spiffe.dom/ghost \
    -parentID spiffe://spiffe.dom/server-node
Entry ID         : fe4b1fd5-9e0a-440b-b08e-5c2c886b6a6e
SPIFFE ID        : spiffe://spiffe.dom/ghost
Parent ID        : spiffe://spiffe.dom/server-node
Revision         : 0
TTL              : default
Selector         : unix:path:/usr/local/bin/ghostunnel
```

上面的命令参数解释如下：

* selector：类似 Kubernetes 中的 Label Selector，用 Workload 属性来界定身份，这里使用的是二进制路径：`unix:path:/usr/local/bin/ghostunnel`，此文件启动之后，可以使用 Workload API 向 Agent 请求 SVID
    
* socketPath：指定 SPIRE Server 的监听 Socket
    
* spiffeID：Workload 的 SPIFFE ID
    
* parentID：Node 的 SPIFFE ID
    

创建这个 Entry 之后，SPIRE Server 会据此创建 SVID 下发给 Agent，Agent 只要根据 Selector 判断 Workload 身份，如果符合就可以发放 SVID 了。

接下来启动 Ghostunnel：

```plaintext
$ ghostunnel server \
    --use-workload-api-addr unix:///$(pwd)/socks/server-side-agent.sock \
    --listen=0.0.0.0:9099 \
    --target=localhost:80 \
    --allow-uri=spiffe://spiffe.dom/curl
```

这里使用了一个参数 `--use-workload-api-addr`，要求使用 SPIFFE Workload API，对应 Agent Socket 为前面生成的 `socks/server-side-agent.sock`。`--listen` 和 `--target` 分别代表了监听端口和被代理端口（也就是 NGINX）。而 `--allow-uri` 参数则是一种访问控制手段，此处是允许 `spiffe://spiffe.dom/curl` 的 SPIFFE ID 访问本服务。除了这种死板的方式之外，Ghostunnel 还能对接 OPA 实现更加复杂的符合生产要求的策略管控能力。

如果此时用浏览器或者 CURL 访问该节点的 9099 端口，就会出现客户端证书不匹配的错误。

## 获取 CURL 客户端证书并测试连接

类似的，我们给 CURL 创建一个 SVID：

```plaintext
$ spire-server entry create \
    -selector unix:uid:1000 \
    -socketPath socks/spire-server.sock \
    -spiffeID spiffe://spiffe.dom/curl \
    -ttl 600 \
    -parentID spiffe://spiffe.dom/client-node
Entry ID         : 50911ef7-f191-4917-adde-1bf4e6192002
SPIFFE ID        : spiffe://spiffe.dom/curl
Parent ID        : spiffe://spiffe.dom/client-node
Revision         : 0
TTL              : 600
Selector         : unix:uid:1000
```

因为我们用的是 CURL，并不具备直接访问 Workload API 的能力，所以这里用了比较特别的参数：

* Selector 设置为当前用户的 ID，也就是说该用户执行的进程是可以匹配到这个 Entry 从而获取 SVID 的
    
* 设置了 10 分钟的 TTL，满足我们后续手动操作的需要
    

然后用 `spire-agent api fetch` 的方式获取证书：

```plaintext
$ spire-agent api fetch \
    --socketPath socks/client-side-agent.sock \
    -write certs
```

命令执行后，会在 `certs` 发现导出的证书文件，CURL 加上这个证书就能成功访问到 NGINX 了。

```plaintext
$ curl -kv https://127.0.0.1:9099 \
    --cert certs/svid.0.pem --key certs/svid.0.key
*   Trying 127.0.0.1:9099...
* Connected to 127.0.0.1 (127.0.0.1) port 9099 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* TLSv1.0 (OUT), TLS header, Certificate Status (22):
...
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
...
```

## 然后

如果观察 `logs` 目录中的日志，会看到在两个 Agent 的目录中会频频出现 Node 和 Workload 的 SVID 轮转的信息。那么如果 Server 挂了呢？这里就会发现，SPIRE Server 是系统中的一个单点，各个 Node 会因为 SVID 无法更新而异常退出，例如：

```plaintext
level=error msg="Agent crashed" error="current SVID has already expired and rotation failed: failed to dial dns:///127.0.0.1:8081: connection error: desc = \"transport: error while dialing: dial tcp 127.0.0.1:8081: connect: connection refused\""
```

因此需要对 SPIRE Server 进行高可用部署。另外这个手工过程中我们也会看到，手工创建 Entry、传播 Bundle 以及获取证书、参数授权等，是不可能适应快速变更的云服务环境的，因此自动注册机制、策略执行机制以及相应的防篡改机制都是 SPIFFE 体系落地的必要条件。

后续还会根据这些问题进行进一步的尝试。
