/networkfs

A linux 5.4.0 kernel module with support for a remote FS with HTTP API

Primary LanguageC

Задание 4. Сетевая файловая система

Ядро Linux — монолитное. Это означает, что все его части работают в общем адресном пространстве. Однако, это не означает, что для добавления какой-то возможности необходимо полностью перекомпилировать ядро. Новую функциональность можно добавить в виде модуля ядра. Такие модули можно легко загружать и выгружать по необходимости прямо во время работы системы.

С помощью модулей можно реализовать свои файловые системы, причём со стороны пользователя такая файловая система ничем не будет отличаться от ext4 или NTFS. В этом задании мы с Вами реализуем упрощённый аналог NFS: все файлы будут храниться на удалённом сервере, однако пользователь сможет пользоваться ими точно так же, как и файлами на собственном жёстком диске.

Мы рекомендуем при выполнении этого домашнего задания использовать отдельную виртуальную машину: любая ошибка может вывести всю систему из строя, и вы можете потерять ваши данные.

Мы проверили работоспособность всех инструкций для дистрибутива Ubuntu 20.04 x64 и ядра версии 5.4.0-90. Возможно, при использовании других дистрибутивов, вы столкнётесь с различными ошибками и особенностями, с которыми вам придётся разобраться самостоятельно.

Выполните задание в ветке networkfs.

Часть 1. Сервер файловой системы

Все файлы и структура директорий хранятся на удалённом сервере. Сервер поддерживает HTTP API, документация к которому доступна по ссылке.

Для получения токенов и тестирования вы можете воспользоваться консольной утилитой curl.

Сервер поддерживает два типа ответов:

  • Бинарные данные: набор байт (char*), который можно скастить в структуру, указанную в описании ответа. Учтите, что первое поле ответа (первые 8 байт) — код ошибки.
  • JSON-объект: человекочитаемый ответ. Для его получения необходимо передавать GET-параметр json.

Формат JSON предлагается использовать только для отладки, поскольку текущая реализация функции connect_to_server работает только с бинарным форматом. Однако, вы можете её доработать и реализовать собственный JSON-парсер.

Для начала работы вам необходимо завести собственный бакет — пространство для хранения файлов, и получить токен для доступа к нему. Это делается следующим запросом:

$ curl https://nerc.itmo.ru/teaching/os/networkfs/v1/token/issue?json
{"status":"SUCCESS","response":"8c6a65c8-5ca6-49d7-a33d-daec00267011"}

Строка 8c6a65c8-5ca6-49d7-a33d-daec00267011 и является токеном, который необходимо передавать во все последующие запросы. Количество токенов и размер файловой системы не ограничены, однако, мы будем вынуждены ограничить пользователей в случае злоупотребления данной возможностью.

Запускать user-space программы из kernel-space затруднительно, поэтому мы реализовывали для вас собственный HTTP-клиент в виде функции connect_to_server (utils.c:73):

int connect_to_server(const char *command, int params_count, const char *params[], const char *token, char *output_buf);
  • const char *command — название метода (list, create, read, write, lookup, unlink, rmdir, link)
  • int params_count — количество параметров
  • const char *params[] — список параметров — строки вида <parameter_name>=<value>
  • const char *token — ваш токен
  • char *output_buf — буфер для сохранения ответа от сервера размером не менее 8 КБ

Функция возвращает 0, если запрос завершён успешно, и код соответствующей ошибки (utils.c:8) в случае неуспешного HTTP-запроса (если не удалось подключиться к серверу, прочитать ответ или если статус HTTP-ответа не равен 200).

Часть 2. Знакомство с простым модулем

Давайте научимся компилировать и подключать тривиальный модуль. Для компиляции модулей ядра нам понадобятся утилиты для сборки и заголовочные файлы. Установить их можно так:

$ sudo apt-get install build-essential linux-headers-`uname -r`

Мы уже подготовили основу для вашего будущего модуля в файле networkfs.c. Познакомьтесь с ней.

Ядру для работы с модулем достаточно двух функций — одна должна инициализировать модуль, а вторая — очищать результаты его работы. Они указываются с помощью module_init и module_exit.

Важное отличие кода для ядра Linux от user-space-кода — в отсутствии в нём стандартной библиотеки libc. Например, в ней же находится функция printf. Мы можем печатать данные в системный лог с помощью функции printk.

Теперь напишем простой Makefile с инструкциями для сборки файла и очистки артефактов:

obj-m += networkfs.o
all:
	make -C /lib/modules/$(shell uname -r)/build M=$(shell pwd) modules
clean:
	make -C /lib/modules/$(shell uname -r)/build M=$(shell pwd) clean

Обратите внимание, что перед командами должен быть символ табуляции: четыре пробела не подойдут.

Соберём модуль:

$ sudo make

Если наш код скомпилировался успешно, в текущей директории появится файл networkfs.ko — это и есть наш модуль. Осталось загрузить его в ядро:

$ sudo insmod networkfs.ko

Однако, мы не увидели нашего сообщения. Оно печатается не в терминал, а в системный лог — его можно увидеть командой dmesg:

$ dmesg
<...>
[  123.456789] Hello, World!

Для выгрузки модуля нам понадобится команда rmmod:

$ sudo rmmod networkfs
$ dmesg
<...>
[  123.987654] Goodbye!

Часть 3. Подготовка файловой системы

Операционная система предоставляет две функции для управления файловыми системами:

  • register_filesystem — сообщает о появлении нового драйвера файловой системы
  • unregister_filesystem — удаляет драйвер файловой системы

В этой части мы начнём работать с несколькими структурами ядра:

  • inode — описание метаданных файла: имя файла, расположение, тип файла (в нашем случае — регулярный файл или директория)
  • dentry — описание директории: список inode внутри неё, информация о родительской директории, …
  • super_block — описание всей файловой системы: информация о корневой директории, …

Функции register_filesystem и unregister_filesystem принимают структуру с описанием файловой системы. Начнём с такой:

struct file_system_type networkfs_fs_type =
{
	.name = "networkfs",
	.mount = networkfs_mount,
	.kill_sb = networkfs_kill_sb
};

Для монтирования файловой системы в этой структуре мы добавили два поля. Первое — mount — указатель на функцию, которая вызывается при монтировании. Например, она может выглядеть так:

struct dentry* networkfs_mount(struct file_system_type *fs_type, int flags, const char *token, void *data)
{
	struct dentry *ret;
	ret = mount_nodev(fs_type, flags, data, networkfs_fill_super);
	if (ret == NULL)
	{
		printk(KERN_ERR "Can't mount file system");
	}
	else
	{
		printk(KERN_INFO "Mounted successfuly");
	}
	return ret;
}

Эта функция будет вызываться всякий раз, когда пользователь будет монтировать нашу файловую систему. Например, он может это сделать следующей командой (документация):

$ sudo mount -t networkfs <token> <path>

Опция -t нужна для указания имени файловой системы — именно оно указывается в поле name. Также мы передаём токен, полученный в прошлой части, и локальную директорию, в которую ФС будет примонтирована. Обратите внимание, что эта директория должна быть пуста.

Мы используем функцию mount_nodev, поскольку наша файловая система не хранится на каком-либо физическом устройстве:

struct dentry* mount_nodev(struct file_system_type *fs_type, int flags, void *data, int (*fill_super)(struct super_block *, void *, int));

Последний её аргумент — указатель на функцию fill_super. Эта функция должна заполнять структуру super_block информацией о файловой системе. Давайте начнём с такой функции:

int networkfs_fill_super(struct super_block *sb, void *data, int silent)
{
	struct inode *inode;
	inode = networkfs_get_inode(sb, NULL, S_IFDIR, 1000);
	sb->s_root = d_make_root(inode);
	if (sb->s_root == NULL)
	{
		return -ENOMEM;
	}
	printk(KERN_INFO "return 0\n");
	return 0;
}

Аргументы data и silent нам не понадобятся. В этой функции мы используем ещё одну (пока) неизвестную функцию — networkfs_get_inode. Она будет создавать новую структуру inode, в нашем случае — для корня файловой системы:

struct inode *networkfs_get_inode(struct super_block *sb, const struct inode *dir, umode_t mode, int i_ino)
{
	struct inode *inode;
	inode = new_inode(sb);
	inode->i_ino = i_ino;
	if (inode != NULL)
	{
		inode_init_owner(inode, dir, mode);
	}
	return inode;
}

Давайте поймём, что эта функция делает. Файловой системе нужно знать, где находится корень файловой системы. Для этого в поле s_root мы записываем результат функции d_make_root), передавая ему корневую inode. На сервере корневая директория всегда имеет номер 1000.

Для создания новой inode используем функцию new_inode. Кроме этого, с помощью функции inode_init_owner зададим тип ноды — укажем, что это директория. указать тип этой inode как директорию. Все возможные значения umode_t, перечислены в документации — они позволяют задавать не только тип объекта, но и права доступа.

Второе поле, которое мы определили в file_system_type — поле kill_sb — указатель на функцию, которая вызывается при отмонтировании файловой системы. В нашем случае ничего делать не нужно:

void networkfs_kill_sb(struct super_block *sb)
{
	printk(KERN_INFO "networkfs super block is destroyed. Unmount successfully.\n");
}

Не забудьте зарегистрировать файловую систему в функции инициализации модуля, и удалять её при очистке модуля. Наконец, соберём и примонтируем нашу файловую систему:

$ sudo make
$ sudo insmod networkfs.ko
$ sudo mount -t networkfs 8c6a65c8-5ca6-49d7-a33d-daec00267011 /mnt/ct

Если вы всё правильно сделали, ошибок возникнуть не должно. Тем не менее, перейти в директорию /mnt/ct не выйдет — ведь мы ещё не реализовали никаких функций для навигации по ФС.

Теперь отмонтируем файловую систему:

$ sudo umount /mnt/ct

Часть 4. Вывод файлов и директорий

В базовой версии задания все имена файлов и директорий состоят только из латинских букв, цифр, символов подчёркивания, точек и дефисов.

В прошлой части мы закончили на том, что не смогли перейти в директорию:

$ sudo mount -t networkfs 8c6a65c8-5ca6-49d7-a33d-daec00267011 /mnt/ct
$ cd /mnt/ct
-bash: cd: /mnt/ct: Not a directory

Чтобы это исправить, необходимо реализовать некоторые методы для работы с inode. Чтобы эти методы вызывались, в поле i_op нужной нам ноды необходимо записать структуру inode_operations. Например, такую:

struct inode_operations networkfs_inode_ops =
{
	.lookup = networkfs_lookup,
};

Первая функция, которую мы реализуем — lookup. Именно она позволяет операционной системе определять, что за сущность описывается данной нодой. Сигнатура функции должна быть такой:

struct dentry*
networkfs_lookup(struct inode *parent_inode, struct dentry *child_dentry, unsigned int flag);
  • parent_inode — родительская нода
  • child_dentry — объект, к которому мы пытаемся получить доступ
  • flag — неиспользуемое значение

Пока ничего не будем делать: просто вернём NULL. Если мы заново попробуем повторить переход в директорию, у нас ничего не получится — но уже по другой причине:

$ cd /mnt/ct
-bash: cd: /mnt/ct: Permission denied

Решите эту проблему. Пока сложной системы прав у нас не будет — у всех объектов в файловой системе могут быть права 777. В итоге должно получиться что-то такое:

$ ls -l /mnt/
total 0
drwxrwxrwx 1 root root 0 Oct 24 15:52 ct

После этого мы сможем перейти в /mnt/ct, но не можем вывести содержимое директории. На этот раз нам понадобится не i_op, а i_fop — структура типа file_operations. Реализуем в ней первую функцию — iterate.

struct file_operations networkfs_dir_ops =
{
	.iterate = networkfs_iterate,
};

Эта функция вызывается только для директорий и выводит список объектов в ней (нерекурсивно): для каждого объекта вызывается функция dir_emit, в которую передаётся имя объекта, номер ноды и его тип.

Пример функции networkfs_iterate приведён ниже:

int networkfs_iterate(struct file *filp, struct dir_context *ctx)
{
	char fsname[10];
	struct dentry *dentry;
	struct inode *inode;
	unsigned long offset;
	int stored;
	unsigned char ftype;
	ino_t ino;
	ino_t dino;
	dentry = filp->f_path.dentry;
	inode = dentry->d_inode;
	offset = filp->f_pos;
	stored = 0;
	ino = inode->i_ino;
	while (true)
	{
		if (ino == 100)
		{
			if (offset == 0)
			{
				strcpy(fsname, ".");
				ftype = DT_DIR;
				dino = ino;
			}
			else if (offset == 1)
			{
				strcpy(fsname, "..");
				ftype = DT_DIR;
				dino = dentry->d_parent->d_inode->i_ino;
			}
			else if (offset == 2)
			{
				strcpy(fsname, "test.txt");
				ftype = DT_REG;
				dino = 101;
			}
			else
			{
				return stored;
			}
		}		
		dir_emit(ctx, fsname, strlen(fsname), dino, ftype);
		stored++;
		offset++;
		ctx->pos = offset;
	}
	return stored;
}

Попробуем снова получить список файлов:

$ ls /mnt/ct
ls: cannot access '/mnt/ct/test.txt': No such file or directory
test.txt

Эта ошибка возникла из-за того, что lookup работает только для корневой директории — но не для файла test.txt. Это мы исправим в следующих частях.

Вам осталось реализовать iterate для корневой директории с запросом к серверу.

Часть 5. Навигация по директориям

Теперь мы хотим научиться переходить по директориям. На этом шаге функцию networkfs_lookup придётся немного расширить: если такой файл есть, нужно вызывать функцию d_add, передавая ноду файла. Например, так:

struct dentry *networkfs_lookup(struct inode *parent_inode, struct dentry *child_dentry, unsigned int flag)
{
	ino_t root;
	struct inode *inode;
	const char *name = child_dentry->d_name.name;
	root = parent_inode->i_ino;
	if (root == 100 && !strcmp(name, "test.txt"))
	{
		inode = networkfs_get_inode(parent_inode->i_sb, NULL, S_IFREG, 101);
		d_add(child_dentry, inode);
	}
	else if (root == 100 && !strcmp(name, "dir"))
	{
		inode = networkfs_get_inode(parent_inode->i_sb, NULL, S_IFDIR, 200);
		d_add(child_dentry, inode);
	}
	return NULL;
}

Реализуйте навигацию по файлам и директориям, используя данные с сервера.

Часть 6. Создание и удаление файлов

Теперь научимся создавать и удалять файлы. Добавим ещё два поля в inode_operationscreate и unlink:

Функция networkfs_create вызывается при создании файла и должна возвращать новую inode с помощью d_add, если создать файл получилось. Рассмотрим простой пример:

int networkfs_create(struct inode *parent_inode, struct dentry *child_dentry, umode_t mode, bool b)
{
	ino_t root;
	struct inode *inode;
	const char *name = child_dentry->d_name.name;
	root = parent_inode->i_ino;
	if (root == 100 && !strcmp(name, "test.txt"))
	{
		inode = networkfs_get_inode(parent_inode->i_sb, NULL, S_IFREG | S_IRWXUGO, 101);
		inode->i_op = &networkfs_inode_ops;
		inode->i_fop = NULL;
		d_add(child_dentry, inode);
		mask |= 1;
	}
	else if (root == 100 && !strcmp(name, "new_file.txt"))
	{
		inode = networkfs_get_inode(parent_inode->i_sb, NULL, S_IFREG | S_IRWXUGO, 102);
		inode->i_op = &networkfs_inode_ops;
		inode->i_fop = NULL;
		d_add(child_dentry, inode);
		mask |= 2;
	}
	return 0;
}

Чтобы проверить, как создаются файлы, воспользуемся утилитой touch:

$ touch test.txt
$ ls
test.txt
$ touch new_file.txt
$ ls
test.txt new_file.txt
$

Для удаления файлов определим ещё одну функцию — networkfs_unlink.

int networkfs_unlink(struct inode *parent_inode, struct dentry *child_dentry)
{
	const char *name = child_dentry->d_name.name;
	ino_t root;
	root = parent_inode->i_ino;
	if (root == 100 && !strcmp(name, "test.txt"))
	{
		mask &= ~1;
	}
	else if (root == 100 && !strcmp(name, "new_file.txt"))
	{
		mask &= ~2;
	}	
	return 0;
}

Теперь у нас получится выполнять и команду rm.

$ ls
test.txt new_file.txt
$ rm test.txt
$ ls
new_file.txt
$ rm new_file.txt
$ ls
$

Обратите внимание, что утилита touch проверяет существование файла: для этого вызывается функция lookup.

Часть 7. Создание и удаление директорий

Следующая (и последняя из обязательных) часть нашего задания — создание и удаление директорий. Добавим в inode_operations ещё два поля — mkdir и rmdir. Их сигнатуры можно найти тут.

Если мы всё сделали правильно, теперь мы сможем запустить тесты. Для этого добавьте следующие таргеты в Makefile:

tests: all
	python3 -m tests BasicTestCases -f

bonus-name: all
	python3 -m tests NameTestCases -f

bonus-wr: all
	python3 -m tests WRTestCases -f

bonus-link: all
	python3 -m tests LinkTestCases -f

Для запуска тестов вам понадобится Python 3 и библиотека requests. Запустите тесты и проверьте, что ваше решение работает:

$ sudo make tests
<...>
Ran 12 tests in 32.323s

OK

Часть 8*. Произвольные имена файлов (1 балл)

В этот раз вы можете выполнить любое количество бонусных заданий — баллы суммируются.

Реализуйте возможность создания файлов и директорий, состоящих из любых печатных символов, кроме символа / и '. Пример команды, которая можно будет исполнить:

$ touch '!@#$%^&*()-+ '
$ ls
'!@#$%^&*()-+ "

Если вы всё сделали правильно, пройдёт тестовый набор bonus-name:

$ sudo make bonus-name
<...>
Ran 3 tests in 1.814s

OK

Часть 9*. Чтение и запись в файлы (2 балла)

Реализуйте чтение из файлов и запись в файлы. Для этого вам понадобится структура file_operations не только для директорий, но и для обычных файлов.

В неё вам понадобится добавить два поля — read и write. Соответствующие функции имеют следующие сигнатуры:

ssize_t networkfs_read(struct file *filp, char *buffer, size_t len, loff_t *offset);
ssize_t networkfs_write(struct file *filp, const char *buffer, size_t len, loff_t *offset);

Аргументы такие:

  • filp — файловый дескриптор
  • buffer — буфер в user-space для чтения и записи соответственно
  • len — длина данных для записи
  • offset — смещение

Обратите внимание, что просто так обратиться в buffer нельзя, поскольку он находится в user-space. Использкйте функцию get_user для чтения и put_user для записи.

В результате вы сможете сделать вот так:

$ cat file1
hello world from file1
$ cat file2
file2 content here
$ echo "test" > file1
$ cat file1
test
$

Обратите внимание, что файл должен уметь содержать любые ASCII-символы с кодами от 0 до 127 включительно.

Если вы всё сделали правильно, пройдёт тестовый набор bonus-wr:

$ sudo make bonus-wr
<...>
Ran 5 tests in 1.953s

OK

Часть 10*. Жёсткие ссылки (+1 балл)

Вам необходимо поддержать возможность сослаться из разных мест файловой системы на одну и ту же inode.

Обратите внимание: сервер поддерживает жёсткие ссылки только для регулярных файлов, но не для директорий.

Для этого добавьте поле link в структуру inode_operations. Сигнатура соответствующей функции выглядит так:

int networkfs_link(struct dentry *old_dentry, struct inode *parent_dir, struct dentry *new_dentry);

После реализации функции вы сможете выполнить следующие команды:

$ ln file1 file3
$ cat file1
hello world from file1
$ cat file3
hello world from file1
$ echo "test" > file1
$ rm file1
$ cat file3
test
$

Если вы не делали девятую часть, вы всегда можете проверить работу ссылок через запросы к серверу по HTTP: в свежесозданной ссылке будут те же данные, что и в изначальном файле. Тесты не проверяют, что вы умеете читать и писать в файлы.

Если вы всё сделали правильно, пройдёт тестовый набор bonus-link:

$ sudo make bonus-link
<...>
Ran 2 tests in 1.535s

OK