Skip to main content

控制台 API

JuiceFS Web 控制台提供 API,凡是用户在浏览器能实现的文件系统相关交互操作,都可以通过 API 来完成,例如创建文件系统、浏览文件列表,查看回收站和设置 ACL 等。

但也需要注意,控制台无法操纵或修改文件系统内的文件,因此你同样无法通过控制台 API 来直接访问或修改文件系统内的文件。如果需要编程地访问文件系统,可以考虑使用 S3 网关

本文主要介绍请求签名和身份认证,查看 API schema 文档 以了解控制台支持哪些 API 以及 API 的数据格式。

URL 和数据格式

对于 JuiceFS 云服务用户,API URL 是 https://juicefs.com/api/v1。私有部署用户需要将 URL 访问地址进行相应修改,比如 http://console.example.com:8080

使用 JSON 作为数据交换格式。以获取文件系统列表为例,请求参数如下:

GET /api/v1/volumes

Host: juicefs.com
Authorization: 4dca13af31e45740c1c1fe3acaca8752a093b43ccc169f890f1305f51f038bf8
...

返回的数据为:

[
{
"id": 1,
"name": "test",
"region": 1,
"bucket": "http://test.s3.us-east-1.amazonaws.com",
"blockSize": 4096,
"compress": "lz4",
"compatible": false,
"access_rules": [
{
"iprange": "*",
"token": "ec4da70f494f58aac5eee391e6c5986f0b99945",
"readonly": false,
"appendonly": false
}
],
"owner": 5,
"size": 20480,
"inodes": 5,
"created": "2022-07-14T09:57:10.888914Z",
"extend": "",
"storage": null
}
]

使用指南

创建 API 密钥对

  • 在控制台点击右上角用户名,进入账户设置页面
  • 点击「添加新的 API 密钥对」按钮,填写信息并创建
  • 及时记下密钥对,关闭对话框后,将无法再次查看

请求签名

在发送 API 请求之前,还需要使用 API 密钥对对请求进行签名。

为了方便描述,假设创建的 API 密钥对为:

  • Access key: ac7418402ce0ce838ba87eb3a6be72af313cd7028e18007799c0d5651c326925
  • Secret key: 5f0c5a5d51515947788fa7b8244acebe166aedd9de28b26ef716888a613c3d92

另外,以创建文件系统的请求为例:

POST /api/v1/volumes?a=1&a=2&b=3&c=4

Host: juicefs.com
Content-Type: application/json
...


{"name": "test", "bucket": "https://test.s3.us-east-1.amazonaws.com"}
备注

上面的请求参数 a=1&a=2&b=3&c=4 实际上是不存在的,这里加上是为了方便介绍签名算法。

签名算法如下:

  • 记录下当前的时间戳,假设等于 1663245320

    服务端会通过时间戳判断此次请求的发起时间,如果和服务器收到请求的时间相差超过 5 分钟,服务器会抛弃该请求。

  • 按顺序处理请求头,字段名转为小写字符,然后用 : 连接字段名和值,最后用 \n 连接所有字段。目前参与计算的请求头只有 Host。

    得到的结果为 host:juicefs.com

  • 对查询参数进行排序和编码,如果没有查询参数则使用空字符串。

    • 排序规则:先按照字段名排序,因为需要处理同一个字段对应多个值的情况,所以还需要对字段的值进行排序
    • 编码规则:对字段名和值进行 URL 编码,然后用 = 连接字段名和值,最后用 & 连接所有字段

    请求参数 a=1&a=2&b=3&c=4 中包含三个字段 a, b, c,其中字段 a 有两个值。先按字段名排序得到 a < b < c,对于字段 a 还需要对它的两个值进行排序,最终得到的结果为 a=1&a=2&b=3&c=4

  • 对请求体进行 SHA256 哈希,如果没有请求体则使用空字符串。

    得到的结果为 a81f7bf3a5740146fe1eedc891f1f8f063dc428a88ac590147d1cf056bdad04b

  • \n 按顺序拼接时间戳、请求方法、请求路径、请求头、查询参数和请求体。

    得到的结果为:

    1663245320\n
    POST\n
    /api/v1/volumes\n
    host:juicefs.com\n
    a=1&a=2&b=3&c=4\n
    a81f7bf3a5740146fe1eedc891f1f8f063dc428a88ac590147d1cf056bdad04b
  • 使用 Secret key 对拼接后的字符串进行 HMAC-SHA256 签名。

    得到的结果为 3646d11235b08cd856278cb68bd5d2bc7aeec5c593590813e1da43a22d3a9835

备注
  • 参与签名的 Host 字段应该和实际请求头中的 Host 保持一致。一般 HTTP 库都支持显式设置请求头,或者先构造 Request 对象再从中获取实际的 Host。
  • 对于请求体数据,一般也需要先构造 Request 对象,然后从中获取实际的二进制数据。

身份认证

计算完签名之后,还需要使用 Access key、时间戳(请求签名中使用的时间戳)、签名和版本号生成一个 Token 用于身份认证。

版本号是可选的,目前只有一个版本,将其设置为 1 即可。

步骤如下:

  • 将这些字段组成一个 JSON 格式的数据,得到的结果为:

    {
    "access_key": "ac7418402ce0ce838ba87eb3a6be72af313cd7028e18007799c0d5651c326925",
    "timestamp": 1663245320,
    "signature": "3646d11235b08cd856278cb68bd5d2bc7aeec5c593590813e1da43a22d3a9835",
    "version": 1
    }
  • 使用 base64 对上一步的 JSON 数据进行编码,得到结果为:

    ewogICJhY2Nlc3Nfa2V5IjogImFjNzQxODQwMmNlMGNlODM4YmE4N2ViM2E2YmU3MmFmMzEzY2Q3MDI4ZTE4MDA3Nzk5YzBkNTY1MWMzMjY5MjUiLAogICJ0aW1lc3RhbXAiOiAxNjYzMjQ1MzIwLAogICJzaWduYXR1cmUiOiAiMzY0NmQxMTIzNWIwOGNkODU2Mjc4Y2I2OGJkNWQyYmM3YWVlYzVjNTkzNTkwODEzZTFkYTQzYTIyZDNhOTgzNSIsCiAgInZlcnNpb24iOiAxCn0=
  • 将上一步的结果放入到请求头中,例如 Authorization: ewogICJhY2Nlc3Nfa2V5I...24iOiAxCn0=

示例代码

为了更好地说明请求签名和身份认证的过程,下面提供 Python 和 Go 的示例代码:

import base64
import hashlib
import hmac
import json
import time
from typing import Dict, List, Literal, Optional, Union
from urllib.parse import quote, urlencode, urlsplit

import requests


API_URL = 'http://localhost:8080/api/v1'
ACCESS_KEY = 'ac7418402ce0ce838ba87eb3a6be72af313cd7028e18007799c0d5651c326925'
SECRET_KEY = '5f0c5a5d51515947788fa7b8244acebe166aedd9de28b26ef716888a613c3d92'


def sign(
secret_key: str,
timestamp: int,
method: Literal['GET', 'POST', 'PATCH', 'PUT', 'DELETE'],
path: str,
headers: Dict[str, str],
query_params: Optional[Dict[str, Union[str, List[str]]]],
body: Optional[bytes],
) -> str:
"""
参数说明:
- timestamp 是整数时间戳,以秒为单位
- method 是 HTTP 请求方法,例如 GET, POST, PUT, DELETE
- path 是 HTTP 请求路径,注意不包括查询参数,例如 /api/v1/regions
- headers 是 HTTP 请求头,例如 {'Host': 'juicefs.com'}
- query_params 是 HTTP 请求的查询参数,如果同一个字段对应多个值,请使用列表来保存
例如 {'page': '1', 'per_page': '10', 'sort': ['name', 'created_at']}
- body 是 HTTP 请求的原始 body 内容,对于大多数 HTTP 库来说,可能需要先创建一个 Request 对象,然后再从该对象中获取 body 内容
"""
# 1. 按顺序处理请求头,字段名转为小写字符,然后用 `:` 连接字段名和值,最后用 `\n` 连接所有字段。
# 得到的结果形如 `host:juicefs.com`
sorted_headers = []
for h in ['Host']:
v = headers.get(h, '')
if not v:
raise ValueError(f'header {h} is required')
sorted_headers.append(f'{h.lower()}:{v}')
sorted_headers = '\n'.join(sorted_headers)

# 2. 对查询参数进行排序和编码
# 排序规则:先按照字段名排序,因为需要处理同一个字段对应多个值的情况,所以还需要对字段的值进行排序
# 编码规则:对字段名和值进行 URL 编码,然后用 `=` 连接字段名和值,最后用 `&` 连接所有字段
# 得到的结果形如 `a=1&a=2&b=3&c=4`
sorted_qs = ''
if query_params:
sorted_qs = sorted(query_params.items(), key=lambda item: item[0])
for i, (k, values) in enumerate(sorted_qs):
if not isinstance(values, list):
values = [values]
sorted_qs[i] = (k, sorted(values))
sorted_qs = urlencode(sorted_qs, doseq=True, quote_via=quote)

# 3. 对请求体进行 SHA256 哈希
payload_hash = hashlib.sha256(body).hexdigest() if body else ''

# 4. 用 `\n` 按顺序拼接上面的所有字符串
parts = [str(timestamp), method, path, sorted_headers, sorted_qs, payload_hash]
data = '\n'.join(parts)

# 5. 对拼接后的字符串进行 HMAC-SHA256 签名
signature = hmac.new(secret_key.encode('utf-8'), data.encode('utf-8'), hashlib.sha256).hexdigest()

return signature


def request(
method: Literal['GET', 'POST', 'PATCH', 'PUT', 'DELETE'],
url: str,
query_params: Optional[dict] = None,
data: Optional[dict] = None,
):
timestamp = int(time.time())
parts = urlsplit(url)
path = parts.path
headers = {'Host': parts.netloc}

req = requests.Request(method, url, params=query_params, json=data).prepare()
body = req.body or b''

print(f'secret_key: {SECRET_KEY[:10]}...{SECRET_KEY[-10:]}')
print(f'timestamp: {timestamp}')
print(f'method: {method}')
print(f'path: {path}')
print(f'headers: {headers}')
print(f'query_params: {query_params if query_params else "empty"}')
print(f'body: {body if body else "empty"}')

signature = sign(SECRET_KEY, timestamp, method, path, headers, query_params, body)
print(f'signature: {signature}')

auth = {'access_key': ACCESS_KEY, 'timestamp': timestamp, 'signature': signature, 'version': 1}
token = base64.b64encode(json.dumps(auth).encode()).decode()
print(f'token: {token}')

req.headers['Authorization'] = token
resp = requests.Session().send(req)

print('response:')
print(json.dumps(resp.json(), indent=2))


def get_volumes():
url = f'{API_URL}/volumes'
request('GET', url)


def delete_volume():
url = f'{API_URL}/volumes/1'
request('DELETE', url)


def get_volume_exports():
url = f'{API_URL}/volumes/1/exports'
request('GET', url)


def create_volume_export():
url = f'{API_URL}/volumes/1/exports'
request(
'POST',
url,
data={
'desc': 'for mount',
'iprange': '192.168.0.1/24',
'apionly': False,
'readonly': False,
'appendonly': False,
},
)


def update_volume_export():
url = f'{API_URL}/volumes/1/exports/1'
request('PUT', url, data={'desc': 'for mount', 'iprange': '192.168.100.1/24'})


def get_volume_quotas():
url = f'{API_URL}/volumes/1/quotas'
request('GET', url)


def create_volume_quota():
url = f'{API_URL}/volumes/1/quotas'
request('POST', url, data={'path': '/path/to/subdir', 'inodes': 1 << 20, 'size': 1 << 30})


def update_volume_quota():
url = f'{API_URL}/volumes/1/quotas/9'
request('PUT', url, data={'path': '/foo', 'size': 10 << 30})