控制台 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 的示例代码:
- 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})
package main
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
)
const (
API_URL = "http://localhost:8080/api/v1"
ACCESS_KEY = "ac7418402ce0ce838ba87eb3a6be72af313cd7028e18007799c0d5651c326925"
SECRET_KEY = "5f0c5a5d51515947788fa7b8244acebe166aedd9de28b26ef716888a613c3d92"
)
// 参数说明:
//
// 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 内容
func sign(
secretKey string,
timestamp int64,
method string,
path string,
headers http.Header,
queryParams url.Values,
body []byte,
) (string, error) {
// 1. 按顺序处理请求头,字段名转为小写字符,然后用 `:` 连接字段名和值,最后用 `\n` 连接所有字段。
// 得到的结果形如 `host:juicefs.com`
sortedHeaders := []string{}
for _, h := range []string{"Host"} {
v := headers.Get(h)
if v == "" {
return "", fmt.Errorf("header %s is required", h)
}
sortedHeaders = append(sortedHeaders, fmt.Sprintf("%s:%s", strings.ToLower(h), v))
}
srotedHeadersString := strings.Join(sortedHeaders, "\n")
// 2. 对查询参数进行排序和编码
// 排序规则:先按照字段名排序,因为需要处理同一个字段对应多个值的情况,所以还需要对字段的值进行排序
// 编码规则:对字段名和值进行 URL 编码,然后用 `=` 连接字段名和值,最后用 `&` 连接所有字段
// 得到的结果形如 `a=1&a=2&b=3&c=4`
sortedQueryString := ""
if queryParams != nil {
sortedKeys := make([]string, 0, len(queryParams))
for k := range queryParams {
sortedKeys = append(sortedKeys, k)
}
sort.Strings(sortedKeys)
sortedQueryParams := make([]string, 0, len(queryParams))
for _, k := range sortedKeys {
sort.Strings(queryParams[k])
for _, value := range queryParams[k] {
sortedQueryParams = append(sortedQueryParams, fmt.Sprintf("%s=%s", url.QueryEscape(k), url.QueryEscape(value)))
}
}
sortedQueryString = strings.Join(sortedQueryParams, "&")
}
// 3. 对请求体进行 SHA256 哈希
payloadHash := ""
if body != nil {
hash := sha256.New()
hash.Write(body)
payloadHash = hex.EncodeToString(hash.Sum(nil))
}
// 4. 用 `\n` 按顺序拼接上面的所有字符串
parts := []string{
fmt.Sprintf("%d", timestamp),
method,
path,
srotedHeadersString,
sortedQueryString,
payloadHash,
}
data := strings.Join(parts, "\n")
// 5. 对拼接后的字符串进行 HMAC-SHA256 签名
hash := hmac.New(sha256.New, []byte(secretKey))
hash.Write([]byte(data))
signature := hex.EncodeToString(hash.Sum(nil))
return signature, nil
}
func request(
method string,
api_url string,
queryParams url.Values,
data map[string]interface{},
) error {
var (
body []byte
err error
json_data []byte
)
timestamp := time.Now().Unix()
api_url = fmt.Sprintf("%s?%s", api_url, queryParams.Encode())
if data != nil {
body, err = json.Marshal(data)
if err != nil {
return err
}
}
req, err := http.NewRequest(method, api_url, bytes.NewBuffer(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Host", req.URL.Host)
fmt.Printf("secret_key: %s...%s\n", SECRET_KEY[:10], SECRET_KEY[len(SECRET_KEY)-10:])
fmt.Printf("timestamp: %d\n", timestamp)
fmt.Printf("method: %s\n", method)
fmt.Printf("path: %s\n", req.URL.Path)
json_data, err = json.Marshal(req.Header)
if err != nil {
return err
}
fmt.Printf("headers: %s\n", string(json_data))
fmt.Printf("query_params: %v\n", req.URL.RawQuery)
fmt.Printf("body: %s\n", body)
signature, err := sign(SECRET_KEY, timestamp, method, req.URL.Path, req.Header, queryParams, body)
if err != nil {
return err
}
fmt.Printf("signature: %s\n", signature)
auth := map[string]interface{}{
"access_key": ACCESS_KEY,
"timestamp": timestamp,
"signature": signature,
"version": 1,
}
jsonString, err := json.Marshal(auth)
if err != nil {
return err
}
token := base64.StdEncoding.EncodeToString(jsonString)
fmt.Printf("token: %s\n", token)
req.Header.Set("Authorization", token)
client := http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
resp_body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
fmt.Printf("response: \n%s\n", string(resp_body))
return nil
}
func getVolumes() error {
u := fmt.Sprintf("%s/volumes", API_URL)
return request("GET", u, nil, nil)
}
func deleteVolume() error {
u := fmt.Sprintf("%s/volumes/1", API_URL)
return request("DELETE", u, nil, nil)
}
func getVolumeExports() error {
u := fmt.Sprintf("%s/volumes/1/exports", API_URL)
return request("GET", u, nil, nil)
}
func createVolumeExport() error {
u := fmt.Sprintf("%s/volumes/1/exports", API_URL)
return request(
"POST",
u,
nil,
map[string]interface{}{
"desc": "for mount",
"iprange": "192.168.0.1/24",
"apionly": false,
"readonly": false,
"appendonly": false,
},
)
}
func updateVolumeExport() error {
u := fmt.Sprintf("%s/volumes/1/exports/1", API_URL)
return request("PUT", u, nil, map[string]interface{}{"desc": "abc", "iprange": "192.168.100.1/24"})
}
func getVolumeQuotas() error {
u := fmt.Sprintf("%s/volumes/1/quotas", API_URL)
return request("GET", u, nil, nil)
}
func createVolumeQuota() error {
u := fmt.Sprintf("%s/volumes/1/quotas", API_URL)
return request(
"POST",
u,
nil,
map[string]interface{}{"path": "/path/to/subdir", "inodes": 1 << 20, "size": 1 << 30},
)
}
func updateVolumeQuota() error {
u := fmt.Sprintf("%s/volumes/1/quotas/1", API_URL)
return request("PUT", u, nil, map[string]interface{}{"path": "/foo", "size": 10 << 30})
}
func main() {
if err := getVolumes(); err != nil {
fmt.Printf("request error: %s", err)
}
}