Skip to content

Commit

Permalink
cockpit: support setting owner/group in fsreplace1
Browse files Browse the repository at this point in the history
Cockpit-files wants to support uploading or creating a file owned by the
current directory which might be different from the logged in user.

For example as superuser uploading a database into `/var/lib/postgresql`
which would be owned by `postgres` and the database file should receive
the same permissions.
  • Loading branch information
jelly committed Nov 21, 2024
1 parent 035dc2d commit 8554761
Show file tree
Hide file tree
Showing 9 changed files with 338 additions and 7 deletions.
7 changes: 7 additions & 0 deletions doc/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -1072,6 +1072,13 @@ The following options can be specified in the "open" control message:
you don't set this field, the actual tag will not be checked. To
express that you expect the file to not exist, use "-" as the tag.

* "attrs": a JSON object containing file owner and group information
to set. Supporting the file ownership fields returned by `fsinfo`.
* `uid`: an integer, the uid of the file owner (`st_uid`)
* `owner`: a string, or an integer, the uid of the file owner (`st_uid`)
* `gid`: an integer, the gid of the file group (`st_gid`)
* `group`: a string, or an integer, the gid of the file group (`st_gid`)

You should write the new content to the channel as one or more
messages. To indicate the end of the content, send a "done" message.

Expand Down
9 changes: 8 additions & 1 deletion pkg/lib/cockpit.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,9 +257,16 @@ declare module 'cockpit' {
remove(): void;
}

interface ReplaceAttrs {
uid?: number;
user?: string | number;
gid?: number;
group?: string | number;
}

interface FileHandle<T> {
read(): Promise<T>;
replace(new_content: T | null, expected_tag?: FileTag): Promise<FileTag>;
replace(new_content: T | null, expected_tag?: FileTag, attrs?: ReplaceAttrs): Promise<FileTag>;
watch(callback: FileWatchCallback<T>, options?: { read?: boolean }): FileWatchHandle;
modify(callback: (data: T | null) => T | null, initial_content?: string, initial_tag?: FileTag): Promise<[T, FileTag]>;
close(): void;
Expand Down
8 changes: 6 additions & 2 deletions pkg/lib/cockpit.js
Original file line number Diff line number Diff line change
Expand Up @@ -2231,7 +2231,7 @@ function factory() {

let replace_channel = null;

function replace(new_content, expected_tag) {
function replace(new_content, expected_tag, attrs) {
const dfd = cockpit.defer();

let file_content;
Expand All @@ -2249,8 +2249,12 @@ function factory() {
...base_channel_options,
payload: "fsreplace1",
path,
tag: expected_tag
tag: expected_tag,
};

if (attrs)
opts.attrs = attrs;

replace_channel = cockpit.channel(opts);

replace_channel.addEventListener("close", function (event, message) {
Expand Down
15 changes: 15 additions & 0 deletions pkg/playground/test.html
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,21 @@
<p>cockpit.user() information</p>
<div id="user-info" />
</div>
<br/>
<div id="fsreplace1-div">
<h2>fsreplace1 test</h2>
<p>new filename</p>
<input id="fsreplace1-filename" />
<p>text content</p>
<input id="fsreplace1-content" />
<p>owner mode</p>
<input id="fsreplace1-owner" placeholder="For example, admin" />
<input id="fsreplace1-group" placeholder="For example, users" />
<input id="fsreplace1-uid" placeholder="For example, 1000" />
<input id="fsreplace1-gid" placeholder="For example, 1000" />
<button id="fsreplace1-create" class="pf-v5-c-button pf-m-secondary">Create file</button>
<div id="fsreplace1-error"></div>
</div>
</section>
</main>
</div>
Expand Down
28 changes: 28 additions & 0 deletions pkg/playground/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,34 @@ document.addEventListener("DOMContentLoaded", () => {
document.getElementById("user-info").textContent = JSON.stringify(info);
});

const fsreplace_btn = document.getElementById("fsreplace1-create");
const fsreplace_error = document.getElementById("fsreplace1-error");
fsreplace_btn.addEventListener("click", e => {
fsreplace_btn.disabled = true;
fsreplace_error.textContent = '';
const filename = document.getElementById("fsreplace1-filename").value;
const content = document.getElementById("fsreplace1-content").value;
const attrs = { };
for (const field of ["owner", "group", "uid", "gid"]) {
let val = document.getElementById(`fsreplace1-${field}`).value;
if (!val)
continue;

if (field.endsWith('id'))
val = Number.parseInt(val, 10);

attrs[field] = val;
}
cockpit.file(filename, { superuser: "try" }).replace(content, undefined, attrs).then(() => {
fsreplace_btn.disabled = false;
})
.catch(exc => {
console.log(exc);
fsreplace_error.textContent = exc;
fsreplace_btn.disabled = false;
});
});

cockpit.addEventListener("visibilitychange", show_hidden);
show_hidden();
});
80 changes: 77 additions & 3 deletions src/cockpit/channels/filesystem.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@
JsonObject,
get_bool,
get_int,
get_object,
get_str,
get_str_or_int,
get_strv,
json_merge_and_filter_patch,
)
Expand Down Expand Up @@ -158,6 +160,64 @@ def do_yield_data(self, options: JsonObject) -> Generator[bytes, None, JsonObjec
raise ChannelError('internal-error', message=str(exc)) from exc


class FSReplaceAttrs:
supported_attrs = ['uid', 'gid', 'owner', 'group']

def __init__(self, value: JsonObject) -> None:
# Any unknown keys throw an error
unsupported_attrs = list(value.keys() - self.supported_attrs)
if unsupported_attrs:
raise ChannelError('protocol-error',
message=f'"attrs" contains unsupported key(s) {unsupported_attrs}') from None

self.uid = get_int(value, 'uid', None)
self.gid = get_int(value, 'gid', None)

if self.uid is None and self.gid is not None:
raise ChannelError('protocol-error', message='cannot provide a gid without an uid')

try:
owner = get_str_or_int(value, 'owner', None)
except JsonError:
raise ChannelError('protocol-error', message='"owner" must be an integer or string') from None

try:
group = get_str_or_int(value, 'group', None)
except JsonError:
raise ChannelError('protocol-error', message='"group" must be an integer or string') from None

if owner is None and group is not None:
raise ChannelError('protocol-error', message='cannot provide a group without an owner')

if owner is not None:
if isinstance(owner, str):
try:
uid = pwd.getpwnam(owner).pw_uid
except KeyError:
raise ChannelError('internal-error', message=f'uid not found for {owner}') from None

if self.uid is not None and self.uid != uid:
raise ChannelError('protocol-error', message='"owner" uid does not match passed "uid"') from None

self.uid = uid
else:
self.uid = owner

if group is not None:
if isinstance(group, str):
try:
gid = grp.getgrnam(group).gr_gid
except KeyError:
raise ChannelError('internal-error', message=f'gid not found for {group}') from None

if self.gid is not None and self.gid != gid:
raise ChannelError('protocol-error', message='"group" gid does not match passed "gid"') from None

self.gid = gid
else:
self.gid = group


class FsReplaceChannel(AsyncChannel):
payload = 'fsreplace1'

Expand All @@ -168,10 +228,21 @@ def delete(self, path: str, tag: 'str | None') -> str:
os.unlink(path)
return '-'

async def set_contents(self, path: str, tag: 'str | None', data: 'bytes | None', size: 'int | None') -> str:
async def set_contents(self, path: str, tag: 'str | None', data: 'bytes | None', size: 'int | None',
attrs: 'FSReplaceAttrs | None') -> str:
dirname, basename = os.path.split(path)
tmpname: str | None
fd, tmpname = tempfile.mkstemp(dir=dirname, prefix=f'.{basename}-')

def chown_if_required(fd: 'int'):
if attrs is None:
return

if attrs.uid is not None and attrs.gid is not None:
os.fchown(fd, attrs.uid, attrs.gid)
elif attrs.uid is not None:
os.fchown(fd, attrs.uid, attrs.uid)

try:
if size is not None:
logger.debug('fallocate(%s.tmp, %d)', path, size)
Expand All @@ -195,12 +266,14 @@ async def set_contents(self, path: str, tag: 'str | None', data: 'bytes | None',
# no preconditions about what currently exists or not
# calculate the file mode from the umask
os.fchmod(fd, 0o666 & ~my_umask())
chown_if_required(fd)
os.rename(tmpname, path)
tmpname = None

elif tag == '-':
# the file must not exist. file mode from umask.
os.fchmod(fd, 0o666 & ~my_umask())
chown_if_required(fd)
os.link(tmpname, path) # will fail if file exists

else:
Expand All @@ -225,22 +298,23 @@ async def run(self, options: JsonObject) -> JsonObject:
path = get_str(options, 'path')
size = get_int(options, 'size', None)
tag = get_str(options, 'tag', None)
attrs = get_object(options, 'attrs', FSReplaceAttrs, None)

try:
# In the `size` case, .set_contents() sends the ready only after
# it knows that the allocate was successful. In the case without
# `size`, we need to send the ready() up front in order to
# receive the first frame and decide if we're creating or deleting.
if size is not None:
tag = await self.set_contents(path, tag, b'', size)
tag = await self.set_contents(path, tag, b'', size, attrs)
else:
self.ready()
data = await self.read()
# if we get EOF right away, that's a request to delete
if data is None:
tag = self.delete(path, tag)
else:
tag = await self.set_contents(path, tag, data, None)
tag = await self.set_contents(path, tag, data, None, attrs)

self.done()
return {'tag': tag}
Expand Down
9 changes: 9 additions & 0 deletions src/cockpit/jsonutil.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,15 @@ def get_str_or_none(obj: JsonObject, key: str, default: Optional[str]) -> Option
return _get(obj, lambda v: None if v is None else typechecked(v, str), key, default)


def get_str_or_int(obj: JsonObject, key: str, default: Optional[Union[str, int]]) -> Optional[Union[str, int]]:
def as_str_or_int(value: JsonValue) -> Union[str, int]:
if not isinstance(value, (str, int)):
raise JsonError(value, 'must be a string or integer')
return value

return _get(obj, as_str_or_int, key, default)


def get_dict(obj: JsonObject, key: str, default: Union[DT, _Empty] = _empty) -> Union[DT, JsonObject]:
return _get(obj, lambda v: typechecked(v, dict), key, default)

Expand Down
Loading

0 comments on commit 8554761

Please sign in to comment.