CNPM - with custom auth server

背景

最近开发了越来越多的内部js库, 库的版本管理就显得越来越重要了. 之前的做法是通过gitlab-ci控制master发布到gitlab-release中, 但是随着库的数量的增长, 以及项目也需要做依赖库的版本管理的需求, 所以考虑搭建一个私有的cnpm仓库来存储内部开发的js

调研

初步看到有两个比较常用的解决方案

  1. cnpm - 阿里出品
  2. verdaccio - 最近流行的开源方案

cnpm的优点是稳定, 相关开发文档全面, 而且使用的用户也比较多. 而verdaccio看起来比较新颖而且有比较多的新特性, 但是开发资料少, 而且很少见到有大型用户使用. 由于我们这个仓库只是作为内部库的registry使用, 不太需要新功能以及人力来维护, 所以最终权衡后, 还是考虑更稳定和使用更广泛的cnpm来搭建自己的私有仓库

实现

需求

  • 稳定存储
  • 权限控制
  • 减少侵入
  • 易于部署

开发私有registry最基础的需求是前面两点: 稳定存储和权限控制. 然后出于维护成本的考虑, 希望能尽可能减少开发量和侵入性修改, 便于后期迭代升级, 也需要减少部署成本, 如更换服务器或迁移机房后重新部署起来不会麻烦

设计

分别解决以上需求的方案如下:

  1. 使用现有的CephFS来解决稳定存储的问题, 虽然cnpm也支持s3存储, 但是在本地开发或测试环境使用s3会比较麻烦, 因此这里直接使用CephFS来存储, 它的体验和本地文件系统是一样的, 同时也是支持多副本存储和共享存储挂载的
  2. 研究了cnpm的用户模块后, 发现只需要实现一个自己的user_service来提供权限认证就可以了, 它已经定义好了需要实现的接口和结构
  3. 为了实现减少代码侵入, 将user_servicescope等管理接口额外抽离出一个独立服务来提供结构给cnpm调用, 避免修改过多的cnpm的代码导致维护困难
  4. 直接容器化部署, 可以很简单的扩缩容, 没有运维压力

开发

docker

cnpm已经提供了docker化的方案, 自带Dockerfiledockerize/config, 修改里面需要调整的内容即可. 本地开发可以直接修改docker-compose.yml里的volume/mysql等配置.

user_service

比较重要的就是这一块代码调整了, cnpm默认使用的default_user_service是直接读取config.js里相关配置实现的, 我们需要同样实现一个user_service, 只是将信息通过接口获取. 其中proto.auth接口就是提供给客户端npm login使用的, 传入参数是用户输入的usernamepassword. 而我们这里为了避免用户泄露密码, 采用的是用户的登录tokenauth平台校验, 避免用户密码泄露

1
2
3
4
5
6
7
// modify user.js
// var DefaultUserService = require('./default_user_service');
// config.userService = new DefaultUserService();
// config.customUserService = false;
var AuthUserService = require('./auth_user_service');
config.userService = new AuthUserService();
config.customUserService = true;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
// auth_user_service.js
'use strict';

var debug = require('debug')('cnpmjs.org:services:auth_user_service');
var request = require('request');
var gravatar = require('gravatar');
// var User = require('../proxy/user');
var User = require('../models').User;
var config = require('../config');

// User: https://github.com/cnpm/cnpmjs.org/wiki/Use-Your-Own-User-Authorization#user-data-structure
// {
// "login": "fengmk2",
// "email": "fengmk2@gmail.com",
// "name": "Yuan Feng",
// "html_url": "http://fengmk2.github.com",
// "avatar_url": "https://avatars3.githubusercontent.com/u/156269?s=460",
// "im_url": "",
// "site_admin": false,
// "scopes": ["@org1", "@org2"]
// }

module.exports = AuthUserService;

const authRequest = async (options) => {
return await new Promise((resolve, reject) => {
request(options, function (error, response) {
if (error) {
return reject(error);
}
if (response.statusCode !== 200) {
return reject(response.statusCode);
}
const result = JSON.parse(response.body);
return resolve(result);
});
});
};

async function convertToUser(row) {
const userWithScopeInfo = await authRequest({
method: 'GET',
url: `${config.userServiceConfig.npmUserServiceAddr}?name=${row.username}`,
headers: {
'X-TOKEN': '// ...',
},
});
debug('userWithScopeInfo: ', userWithScopeInfo);

var user = {
login: row.username,
email: row.email,
name: row.username,
html_url: 'http://cnpmjs.org/~' + row.username,
avatar_url: '',
im_url: `// ...`,
site_admin: !!userWithScopeInfo.data.userInfo.is_super_admin,
scopes: userWithScopeInfo.data.scopeList.map((x) => x.id),
};
if (row.json) {
var data = row.json;
if (data.login) {
// custom user
user = data;
} else {
// npm user
if (data.avatar) {
user.avatar_url = data.avatar;
}
if (data.fullname) {
user.name = data.fullname;
}
if (data.homepage) {
user.html_url = data.homepage;
}
if (data.twitter) {
user.im_url = 'https://twitter.com/' + data.twitter;
}
}
}
if (!user.avatar_url) {
user.avatar_url = gravatar.url(user.email, { s: '50', d: 'retro' }, true);
}
return user;
}

async function getUserInfoByName(name) {
try {
const result = await authRequest({
method: 'GET',
url: `${config.userServiceConfig.npmUserServiceAddr}?name=${name}`,
headers: {
'X-TOKEN': '// ...',
},
});
if (!result || !result.data) {
return null;
}
return [].concat(result.data).find((x) => x && x.username === name);
} catch (e) {
return null;
}
}

function AuthUserService() {}

var proto = AuthUserService.prototype;

/**
* Auth user with login name and password
* @param {String} login login name
* @param {String} password login password
* @return {User}
*/
proto.auth = function* (login, password) {
var row = yield new Promise((resolve) => {
request(
{
method: 'GET',
url: config.userServiceConfig.npmUserLoginAddr,
headers: {
'X-TOKEN': password,
},
},
function (error, response) {
if (error) {
return resolve(null);
}
if (response.statusCode !== 200) {
return resolve(null);
}
const result = JSON.parse(response.body);
if (!result || !result.data) {
return resolve(null);
}
if (result.data.user.username !== login) {
return resolve(null);
}
return resolve(result.data.user);
},
);
});
if (!row) {
return null;
}
return yield convertToUser(row);
};

/**
* Get user by login name
* @param {String} login login name
* @return {User}
*/
proto.get = function* (login) {
var row = yield User.findByName(login);
if (!row) {
return null;
}
var user = yield getUserInfoByName(row.name);
if (!user) {
return null;
}
return yield convertToUser(user);
};

/**
* List users
* @param {Array<String>} logins login names
* @return {Array<User>}
*/
proto.list = function* (logins) {
var rows = yield User.listByNames(logins);
var users = [];
for (var row of rows) {
const user = yield getUserInfoByName(row.name);
users.push(yield convertToUser(user));
}
return users;
};

/**
* Search users
* @param {String} query query keyword
* @param {Object} [options] optional query params
* - {Number} limit match users count, default is `20`
* @return {Array<User>}
*/
proto.search = function* (query, options) {
options = options || {};
options.limit = parseInt(options.limit);
if (!options.limit || options.limit < 0) {
options.limit = 20;
}

var rows = yield User.search(query, options);
var users = [];
for (var row of rows) {
const user = yield getUserInfoByName(row.name);
users.push(yield convertToUser(user));
}
return users;
};

scope_service

完成user_service之后, 私有npm就可以使用了, 但是在上传包时, 发现每个用户只能上传固定scope的包, 研究后发现同样是通过config配置的, 所以同样修改为通过scope_service实现, 同时user_service的用户信息接口中也额外返回此用户有哪些scope权限以及是否超管即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
'use strict';

var request = require('request');
var config = require('../config');

const authRequest = async (options) => {
return await new Promise((resolve, reject) => {
request(options, function (error, response) {
if (error) {
return reject(error);
}
if (response.statusCode !== 200) {
return reject(response.statusCode);
}
const result = JSON.parse(response.body);
return resolve(result);
});
});
};

const scopeService = {
async getAllScope() {
try {
const result = await authRequest({
method: 'GET',
url: `${config.userServiceConfig.npmUserServiceHost}/api/v1/scope/list`,
headers: {
'X-TOKEN': '// ...',
},
});
if (!result || !result.data) {
return [];
}
return result.data.scopeList.map((x) => x.id);
} catch (e) {
return [];
}
},
};

module.exports = scopeService;

然后修改middleware/proxy_to_npmmiddleware/sync_by_install里相关获取scope的逻辑, 改为通过scope_service获取

小结

外部auth_service的实现就不用细讲了, 很简单的curd实现即可, 特殊点的是为了方便, 我们直接使用cnpm自带的mysql的库就行

开发完成后, 通过k8s将共享分布式存储CephFS挂载到容器内data目录下启动, 尝试上传包->重启容器->查看包是否能正常下载, 扩容缩容后服务均能正常运行和下载, 至此就完成了内部npm-registry的搭建

其他

设置scope方便使用

npm现在支持指定特定scope使用特定registry来下载, 如果不使用此命令而是在下载时候指定--registry https://my.registry.com, 偶尔会出现某些包被解析到这个registry目录下但实际上不存在的情况, 测试后发现可能是npm自己的bug, 所以实践上建议通过scope来指定registry

1
2
3
npm config set '@myscope:registry' https://my.registry.com

npm install @myscope/lib

tag使用

在用其他包时如果想要使用最新特性, 往往会通过@next等标签来获取最新版本, 内部发布测试的时候同样可以指定tag来发布, 完成某些功能测试和提供先行体验版本等.

但是得注意的是, 即时是携带tag的版本, 版本号同样不能与正式版本冲突(即版本号为唯一主键), 建议使用@1.0.0-next-{hash:8}携带具体版本信息的字符串作为测试的版本号
1
npm publish --tag next --registry https://my.registry.com

ci-publish自动发布

都有内部registry了, 自动发布流程就也可以安排上了. 由于npm只支持真实用户账号, 所以我们也可以同样直接使用账号的token来进行发布

在本地登录npm login --registry https://my.registry.com后, 可以在~/.npmrc文件下看到属于my.registry.comtoken, 将此token设置到ci环境变量中, 通过脚本在发布时注入即可完成权限验证(此方法也适用于正式npm站点)

1
2
npm config set //my.registry.com/:_authToken ${NPM_TOKEN}
npm publish --verbose --registry https://my.registry.com
作者

Mosby

发布于

2021-04-17

许可协议

评论