先来看看背景,起因是来自于最近在写一个 CI/CD 系统,在打包的过程中需要 制作 docker 镜像,也就是会与 docker daemon 交互。

调试的时候没问题,当放到笔者自己写的进程管理工具运行时,就报错了。

Got permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Post http://%2Fvar%2Frun%2Fdocker.sock/v1.39/build?buildargs=%7B%7D&cachefrom=%5B%5D&cgroupparent=&cpuperiod=0&cpuquota=0&cpusetcpus=&cpusetmems=&cpushares=0&dockerfile=Dockerfile&labels=%7B%7D&memory=0&memswap=0&networkmode=default&rm=1&session=tpiqs5dvsc1yyh7huvieqs6mx&shmsize=0&t=registry.ops.gaoshou.me%2Fposeidon-demo%3ANone&target=&ulimits=null&version=1: dial unix /var/run/docker.sock: connect: permission denied
time="2019-06-26T17:02:50+08:00" level=error msg="failed to dial gRPC: cannot connect to the Docker daemon. Is 'docker daemon' running on this host?: dial unix /var/run/docker.sock: connect: permission denied"

我们知道与本地 docker daemon 交互一般是通过 /var/run/docker.sock 通信的。典型的权限如下

srw-rw---- 1 root docker 0 Jun 26 14:15 /var/run/docker.sock

既然 UNIX-LIKE 系统把设备都当作文件处理,那权限问题首先想到的就是在进程管理 工具下运行的进程,权限到底是不是正确呢? 于是赶紧再写一个简单的测试来验证

command = id
user = someuser

输出

command: id
uid=999(someuser) gid=65534(nogroup) groups=65534(nogroup),0(root)

而实际上这个用户的身份如下

uid=999(someuser) gid=65534(nogroup) groups=65534(nogroup),27(sudo),100(users),105(crontab),118(docker)

于是怀疑切换进程权限时,是否有问题呢。刚开始怀疑是 pola 中执行子进程 使用 /bin/sh 的锅。 debian/ubuntu 系统下 /bin/sh 指向的是 dash 。而实际上 测试下来与 dash 无关。那么答案应该比较清楚,就是 pola 自己的问题了。

我们再来看一下, pola 切换用户需要使用 root 权限或者 sudo 运行,此时顶级的进程 权限是 root:root ,也即 uid=0, gid=0 。对比原来的代码:

void switch_user(const char * user, char ** env)
{

	if (user == NULL || !strcmp("", user))
		return;

	struct passwd *p;
	if ((p = getpwnam(user)) == NULL) {
		fprintf(stderr, "cannot find user: %s\n", user);
		exit(1);
	}

	uid_t uid = getuid();

	if (uid == p->pw_uid) return;

	if (uid != 0) {
		fprintf(stderr, "cannot switch user without root");
		exit(1);
	}

	if (setgid(p->pw_gid) == -1) {
		fprintf(stderr, "cannot setgid -> %d\n", p->pw_gid);
		exit(1);
	}
	printf("[%s] setgid to %d\n", sys_argv[0], p->pw_gid);


	if (setuid(p->pw_uid) == -1) {
		fprintf(stderr, "cannot setuid -> %d\n", p->pw_uid);
		exit(1);
	};
	printf("[%s] setuid to %d\n", sys_argv[0], p->pw_uid);

	snprintf(env[0], strlen(p->pw_name) + 6, "USER=%s", p->pw_name);
	snprintf(env[1], strlen(p->pw_dir) + 6, "HOME=%s", p->pw_dir);
	snprintf(env[2], strlen(p->pw_name) + 7, "LOGIN=%s", p->pw_name);
}

switch_user 方法仅仅调用了 setgidsetuid 。我们看到最终的 uid 与 gid 是 正确的,但是后面的 groups 不对,这是导致无法访问 /var/run/docker.sock 的直接原因

Linux 系统的用户组

在 Linux 或者说 UNIX-like 的操作系统中,每个用户除了自己的用户 id 以及 首要分组id 以外,还可以加入其他附加的分组,这个概念叫做 Supplementary groups 。 当访问资源时,会鉴别除了主要分组外,再加上这个附加分组的身份,来决定最终是否能访问。

实际上,当某个进程访问资源时,其权限并不一定都是由开启进程时的权限决定。 例如进程管理程序中常用的,使用 root 权限的可以通过 setuidsetgid 来 设置 “生效”的uid 与 gid

设置进程附加分组权限

那么既然 uid 与 gid 可以设置,附加分组是否可以设置呢?当然可以,参考 setgroups(2)syscalls(2)

系统调用函数原型:

int setgroups(size_t size, const gid_t * list);

接下来就要根据输入的用户名,先获取具有哪些附加分组。 glibc 与 musl 都提供了 getgrouplist(3) 。不过实际测试发现使用 musl 静态编译后的 getgrouplist 调用 在 glibc 环境下跑会报 ESPIPE / invalid seek 错误。

那稍微麻烦一点,我们自己获取吧。使用 getgrent(3)

int mygetgrouplist(const char *user, gid_t gid, gid_t *groups, int *ngroups)
{
	size_t n, i;
	struct group *gr;
	if (*ngroups<1) return -1;
	n = *ngroups;
	*groups++ = gid;
	*ngroups = 1;

	setgrent();
	while ((gr = getgrent()) && *ngroups < 0x7fffffff) {
		// printf("%d th run...\n", c++);
		for (i=0; gr->gr_mem[i] && strcmp(user, gr->gr_mem[i]); i++);
		if (!gr->gr_mem[i]) continue;
		// printf("gr->gr_gid: %d\n", gr->gr_gid);

		if (++*ngroups <= n) *groups++ = gr->gr_gid;
	}
	endgrent();
	return *ngroups > n ? -1 : *ngroups;
}

这个模仿 getgrouplist ,当实际附加分组超过传递进来的上限时返回 -1 ,并设置数量 提供后一次重新获取。所以外部可以调用两次来获取完整的结果。

	int ngroups = 5;
	gid_t * groups = calloc(ngroups, sizeof(gid_t *));

	if (mygetgrouplist(argv[1], gid, groups, &ngroups) == -1) {
	  // printf("1st getgrouplist failed...\n");
		free(groups);
		groups = calloc(ngroups, sizeof(gid_t *));
		if (mygetgrouplist(argv[1], gid, groups, &ngroups) == -1) {
			perror("2nd getgrouplist error");
			exit(1);
		}
	}

	printf("got %d groups\n", ngroups);

	for (int i = 0;i < ngroups; i++) {
		printf("%d\n", groups[i]);
	}

修复

最终我们在 switch_user 方法的 setuid 与 setgid 前先调用这一段代码。就可以实现 根据输入的用户名,同时设置对应的 uid / gid / supplementary groups

void switch_user(const char * user, char ** env)
{

	if (user == NULL || !strcmp("", user))
		return;

	struct passwd *p;
	if ((p = getpwnam(user)) == NULL) {
		fprintf(stderr, "cannot find user: %s\n", user);
		exit(1);
	}

	uid_t uid = getuid();

	if (uid == p->pw_uid) return;

	if (uid != 0) {
		fprintf(stderr, "cannot switch user without root");
		exit(1);
	}

	// should fix before setuid
	printf("[%s] apply setgroups_fix\n", sys_argv[0]);

	// use first
	int ngroups = 1;
	gid_t * groups = calloc(ngroups, sizeof(gid_t *));

	printf("[%s] >>> get group list\n", sys_argv[0]);
	// first run, use 1 size, intentionally fetch real ngroups size
	// why not vanilla getgrouplist ?
	// musl static build will not work with glibc system
	// invalid seek -> ESPIPE error
	if (mygetgrouplist(p->pw_name, p->pw_gid, groups, &ngroups) == -1) {
		printf("[%s] >>> ngroups: %d\n", sys_argv[0], ngroups);

		free(groups);
		groups = calloc(ngroups, sizeof(gid_t *));
		printf("[%s] >>> got %d groups\n", sys_argv[0], ngroups);
		if (mygetgrouplist(p->pw_name, p->pw_gid, groups, &ngroups) == -1) {
			printf("ngroups: %d\n", ngroups);
			perror("getgrouplist error");
			exit(1);
		}
	}

	printf("[%s] >>> set group list\n", sys_argv[0]);
	if (setgroups(ngroups, groups) == -1) {
		perror("setgroups() failed");
		exit(1);
	}

	if (setgid(p->pw_gid) == -1) {
		fprintf(stderr, "cannot setgid -> %d\n", p->pw_gid);
		exit(1);
	}
	printf("[%s] setgid to %d\n", sys_argv[0], p->pw_gid);


	if (setuid(p->pw_uid) == -1) {
		fprintf(stderr, "cannot setuid -> %d\n", p->pw_uid);
		exit(1);
	};
	printf("[%s] setuid to %d\n", sys_argv[0], p->pw_uid);

	snprintf(env[0], strlen(p->pw_name) + 6, "USER=%s", p->pw_name);
	snprintf(env[1], strlen(p->pw_dir) + 6, "HOME=%s", p->pw_dir);
	snprintf(env[2], strlen(p->pw_name) + 7, "LOGIN=%s", p->pw_name);
}

参考

\_\_END\_\_