FUSE - Wirtualne systemy plików w przestrzeni użytkownika

Wstęp

  • FUSE (ang. Filesystem in Userspace).
  • FUSE jest modułem jądra systemu, który umożliwia programowanie logiki systemu plików w przestrzeni użytkownika.
  • FUSE stanowi bazę popularnego w systemach GNU/Linux sterownika ntfs-3g, który umożliwia dostęp w trybie odczyt/zapis do systemu plików typu NTFS.

W normalnych warunkach dodanie obsługi nowego typu systemu plików wymaga stworzenia i załadowania specjalnie do tego przeznaczonego modułu jądra. Ponadto, do montowania systemów plików zazwyczaj uprawniony jest tylko użytkownik root. Jednak interfejs VFS (ang. Virtual File System) jądra coraz częściej z powodzeniem wykorzystuje się do zastosowań innych, niż zarządzanie danymi przechowywanymi na urządzeniach blokowych. Wobec tych zmian pojawia się potrzeba z jednej strony umożliwienia zwykłym użytkownikom montowania systemów plików i ładowania do nich sterowników na własny użytek i odpowiedzialność, z drugiej - ułatwienia pisania sterowników dla takich systemów nieopartych na urządzeniach blokowych. Framework Filesystem in Userspace (FUSE) jest jedną z możliwości realizacji tych potrzeb.

Zasada działania

FUSE składa się z dwóch głównych komponentów:

  • Modułu (fuse.ko)
  • Biblioteki dostępnej w przestrzeni użytkownika (libfuse.so)

W celu dodania możliwości rozwijania swoich własnych systemów plików należy wykonać jedną z poniższych czynności:

  • Zainstalować pakiet libfuse-dev (np. Debian), lub
  • ściągnąć kod źródłowy biblioteki ze strony http://fuse.sourceforge.net, skompilować i zainstalować.

Systemy plików oparte na FUSE obsługiwane są przez proces działający w przestrzeni użytkownika (tzw. filesystem daemon). Każdy system plików jest obsługiwany przez osobny daemon. Właścicielem procesu-daemona jest użytkownik, który zamontował obsługiwany przez niego system plików (a więc nie musi to być root) :!:

Warto zwrócić uwagę, że w jednej chwili może być uruchomionych wiele daemonów obsługujących systemy plików tego samego typu, nawet z tym samym właścicielem. Operacje na systemie plików obsługiwane przez daemona są dostępne przez interfejs VFS jadra dzięki modułowi fuse.ko. Komunikacja pomiędzy modułem fuse.ko a kodem obsługującym system plików jest realizowana przez bibliotekę libfuse.

Przepływ żądania przy przykładowej operacji na systemie plików opartym na FUSE dobrze ilustruje poniższy obrazek (w tym przykładzie plik example/hello jest daemonem obsługującym system plików zamontowany w katalogu /tmp/fuse):

Bezpieczeństwo

Umożliwienie nieuprzywilejowanym użytkownikom tworzenia i montowania własnych systemów plików może pociągać za sobą wiele zagrożeń: dla całego systemu lub innych użytkowników. Najbardziej oczywistym zapobiega się przez:

  • Ustawianie opcji nodev i nosuid przy montowaniu przez użytkowników nieuprzywilejowanych.
  • Sprawdzanie uprawnień użytkownika montującego do modyfikowania punktu montowania.
  • Dodatkowo uniemożliwia się dostęp do zamontowanego przez nieuprzywilejowanego użytkownika systemu plików wszystkim pozostałym użytkownikom, włączając w to root-a. Ma to zapobiec dwóm rodzajom zagrożeń:
    • Dostarczony system plików nie musi spełniać żadnych warunków. Np. wywołana na nim operacja może się nigdy nie zakończyć. Większość programów (w tym również te kluczowe dla stabilności szeroko pojmowanego systemu) nie jest na to przygotowana.
    • Filesystem daemon może gromadzić informacje o wykonywanych operacjach i przekazywać je swojemu właścicielowi. Jest to potencjalny wyciek informacji. Powyższe ograniczenie możemy znieść, montując system plików z opcją allow_root lub allow_other (aby umożliwić dostęp odpowiednio root-owi lub wszystkim użytkownikom). Jeśli jednak system jest montowany przez użytkownika nieuprzywilejowanego, to aby mógł użyć tych opcji, w pliku konfiguracyjnym FUSE (/etc/fuse.conf), musi być obecna opcja user_allow_other.

Implementacja w C

FUSE definuje listę operacji możliwych do wykonania w ramach implementowanego systemu plików. Lista tych operacji jest przedstawiona w automatycznie wygenerowanej dokumentacji do biblioteki, którą można znaleźć tutaj. Wszystkie z powyższych operacji są zwykłymi wskaźnikami do funkcji, które zostały zgromadzone w strukturze fuse_operations. W większości ich deklaracja oraz semantyka jest podobna lub identyczna do standardowych funkcji operujących na plikach. Wyjątkiem jest przekazywanie informacji o błędach: Zamiast ustawiać odpowiednią wartość zmiennej errno, funkcje powinny zwrócić wartość ujemną, równą co do wartości bezwzględnej kodowi błędu.

Implementacja systemu plików sprowadza się do:

  1. zaimplementowania wybranych funkcji,
  2. utworzenia zmiennej typu struct fuse_operations, w której ustawione zostaną wskaźniki odpowiadające zaimplementowanym funkcjom,
  3. wywołania funkcji fuse_main:
    fuse_main(argc, argv, &my_ops);
    • pierwsze dwa argumenty są analogiczne do wywołania funkcji main,
    • ostatni wskazuje na wartość struktury fuse_operations.
  4. skompilowanie programu i zlinkowanie go go z biblioteką libfuse:
    gcc hello.c `pkg-config fuse --cflags --libs` -o hello

    Do tego celu należy mieć zainstalowany pakiet pkg-config.

Instrukcje do wykonania

1 HelloWorld

  1. Prześledzić, pobrać poniższy program:
    hello.c
    #define FUSE_USE_VERSION 26
     
    #include <fuse.h>
    #include <stdio.h>
    #include <string.h>
    #include <errno.h>
    #include <fcntl.h>
     
    static const char *hello_str = "Hello World!\n";
    static const char *hello_path = "/hello";
     
    static int hello_getattr(const char *path, struct stat *stbuf) {
        int res = 0;
     
        memset(stbuf, 0, sizeof(struct stat));
        if (strcmp(path, "/") == 0) {
            stbuf->st_mode = S_IFDIR | 0755;
            stbuf->st_nlink = 2;
        } else if (strcmp(path, hello_path) == 0) {
            stbuf->st_mode = S_IFREG | 0444;
            stbuf->st_nlink = 1;
            stbuf->st_size = strlen(hello_str);
        } else
            res = -ENOENT;
     
        return res;
    }
     
    static int hello_readdir(const char *path, void *buf, fuse_fill_dir_t filler,
                 off_t offset, struct fuse_file_info *fi) {
        (void) offset;
        (void) fi;
     
        if (strcmp(path, "/") != 0)
            return -ENOENT;
     
        filler(buf, ".", NULL, 0);
        filler(buf, "..", NULL, 0);
        filler(buf, hello_path + 1, NULL, 0);
     
        return 0;
    }
     
    static int hello_open(const char *path, struct fuse_file_info *fi) {
        if (strcmp(path, hello_path) != 0)
            return -ENOENT;
     
        if ((fi->flags & 3) != O_RDONLY)
            return -EACCES;
     
        return 0;
    }
     
    static int hello_read(const char *path, char *buf, size_t size, off_t offset,
                  struct fuse_file_info *fi) {
        size_t len;
        (void) fi;
        if(strcmp(path, hello_path) != 0)
            return -ENOENT;
     
        len = strlen(hello_str);
        if (offset < len) {
            if (offset + size > len)
                size = len - offset;
            memcpy(buf, hello_str + offset, size);
        } else
            size = 0;
     
        return size;
    }
     
    static struct fuse_operations hello_oper = {
        .getattr    = hello_getattr,
        .readdir    = hello_readdir,
        .open       = hello_open,
        .read       = hello_read,
    };
     
    int main(int argc, char *argv[]) {
        return fuse_main(argc, argv, &hello_oper, NULL);
    }
  2. Skompilować program poleceniem:
    gcc hello.c `pkg-config fuse --cflags --libs` -o hello
  3. Zamontować system plików na dwa sposoby:
    1. Jak zwykły użytkownik:
      • mkdir hellofs
      • ./hello hellofs
    2. Jako root:
      • mount -t fuse hello hellofs
  4. W każdym przypadku spróbować wejść (cd) do katalogu hellofs i wyświetlić listę  plików (ls). Można też spróbować użyć do tego celu managera plików nautilus.
  5. Proszę spróbować przeczytać plik hello z katalogu hellofs.
  6. Odnaleźć w kodzie programu miejsca gdzie zdefiniowane zostały:
    • Nazwa pliku hello.
    • Prawa dostępu do katalogu i pliku.
    • Odczyt zawartości katalogu.
    • Odczyt zawartości pliku.
  7. Odmontować system plików:
    1. Jako zwykły użytkownik:
      • fusermount -u hellofs
    2. Jako root:
      • umount hello

2 Własny system plików

Celem tego ćwiczenia będzie stworzenie własnego, prostego systemu plików, którego zadaniem będzie pobieranie informacji o systemie. Działanie systemu plików będzie następujące:

  • Nasz sterownik systemu plików będzie pobierał listę plików z katalogu fuse znajdującego się w katalogu domowym użytkownika i wyświetlał ich nazwy jako pliki wirtualne.
  • Każdy taki plik wykonywalny będzie wykonywał prostą operację mającą na celu odczytanie informacji o systemie np.
    • Nazwa systemu.
    • Lista zalogowanych osób.
    • Informacje o pamięci.
    • Informacje o procesorze.
    • itp.
  • Odczyt któregokolwiek z wirtualnych plików wykonywalnych będzie powodował uruchomienie pliku wykonywalnego i przechwycenie jego wyjścia, które zostanie zaprezentowane jako zawartość odczytywanego pliku wirtualnego.
  • Efektem odczytu któregokolwiek z wirtualnych plików niewykonywalnych będzie zwykłe przeczytanie jego treści.
  • Dodatkowo nasz system plików będzie pozwalał na:
    • Zmianę praw dostępu do pliku.
    • Zapis pliku jeżeli jest to plik niewykonywalny.

Ćwiczenia

Ćwiczenie 1 - Analiza programu
  1. Pobierz poniższy program oraz komplementarne do niego pliki:
    myfs-main.c
    #define FUSE_USE_VERSION 26
    /* ---------------------------------------------------------------------------- */
     
    #include <fuse.h>
    #include <stdio.h>
    #include <string.h>
    #include <errno.h>
    #include <fcntl.h>
     
    #include "myfs_tools.h"
    /* ---------------------------------------------------------------------------- */
     
    static const char *hello_str = "Hello World!\n";
    /* ---------------------------------------------------------------------------- */
     
    static int myfs_getattr(const char *path, struct stat *stbuf) {
        int res = 0;
     
        memset(stbuf, 0, sizeof(struct stat));
        if (strcmp(path, "/") == 0) {
            stbuf->st_mode = S_IFDIR | 0755;
            stbuf->st_nlink = 2;
        } else if(myfs_file_exists(path)) {
            char* fullpath = myfs_file_full_path(path);
            stat(fullpath, stbuf);
            free(fullpath);
        } else
            res = -ENOENT;
     
        return res;
    }
    /* ---------------------------------------------------------------------------- */
     
    static int myfs_readdir(const char *path, void *buf, fuse_fill_dir_t filler,
                 off_t offset, struct fuse_file_info *fi) {
        (void) offset;
        (void) fi;
     
        if (strcmp(path, "/") != 0)
            return -ENOENT;
     
        filler(buf, ".", NULL, 0);
        filler(buf, "..", NULL, 0);  
     
        // open dir
        char* dirname = myfs_dir_get_fuse_home_path();
        DIR* dirptr = myfs_dir_open(dirname);
        free(dirname);
        if(dirptr == NULL)
            return -errno;
     
        // read dir content
        while(1) {
            struct dirent * entry = myfs_dir_read(dirptr);
            if(!entry) break;
            filler(buf, entry->d_name, NULL, 0);
        }
     
        // close dir
        myfs_dir_close(dirptr);
     
        return 0;
    }
    /* ---------------------------------------------------------------------------- */
     
    static int myfs_open(const char *path, struct fuse_file_info *fi) {
     
        if(myfs_file_exists(path) == 0)
            return -ENOENT;
     
        if ((fi->flags & 3) != O_RDONLY)
            return -EACCES;
     
        return 0;
    }
    /* ---------------------------------------------------------------------------- */
     
    static int myfs_read(const char *path, char *outputbuf, size_t size, off_t offset,
                  struct fuse_file_info *fi) {
     
        pid_t pid;
        int pfd[2], i;
        char offbuf[1];
        ssize_t nread;
        ssize_t totalread = 0;
     
        (void) fi;
     
        pipe(pfd);
        pid = fork();
     
        // child process
        if(pid == 0) {
            dup2(pfd[1],STDOUT_FILENO);
            close(pfd[0]);
            close(pfd[1]);
            char* fullpath = myfs_file_full_path(path);
            execlp(fullpath, fullpath, NULL);
            // on error
            close(STDOUT_FILENO);
            exit(0);
        }
     
        // parent process
        else if(pid > 0) {
            close(pfd[1]);
            for(i=0;i<offset;++i)
                if(read(pfd[0], offbuf, 1) == 0)
                    break;
            do {
                nread = read(pfd[0], outputbuf+totalread, size-totalread);
                totalread += nread;
            }
            while((nread != 0) && (totalread < size));
            close(pfd[0]);
        }
     
        else {
            return -EACCES;
        }
     
        return totalread;
    }
    /* ---------------------------------------------------------------------------- */
     
    static struct fuse_operations myfs_oper = {
        .getattr    = myfs_getattr,
        .readdir    = myfs_readdir,
        .open       = myfs_open,
        .read       = myfs_read,
    };
    /* ---------------------------------------------------------------------------- */
     
    int main(int argc, char *argv[]) {
     
        return fuse_main(argc, argv, &myfs_oper, NULL);
    }
    /* ---------------------------------------------------------------------------- */
    myfs_tools.c
    #include "myfs_tools.h"
     
    /* ---------------------------------------------------------------------------- */
     
    char* myfs_dir_get_fuse_home_path() {
        struct passwd *pw = getpwuid(getuid());
        char* result;
        if(pw == NULL)
            return NULL;
     
        result = (char*)malloc(sizeof(char)*(strlen(pw->pw_dir))+1+5);
        strcpy(result, pw->pw_dir);
        strcat(result, "/fuse");
        return result;
    }
    /* ---------------------------------------------------------------------------- */
     
    char* myfs_file_full_path(const char* fusepath) {
        char* dirname = myfs_dir_get_fuse_home_path();
        char* fullpath = (char*)malloc(sizeof(char)*(strlen(dirname) + strlen(fusepath) + 1));
        strcpy(fullpath, dirname);
        strcat(fullpath, fusepath);
        free(dirname);
     
        return fullpath;
    }
    /* ---------------------------------------------------------------------------- */
     
    int myfs_file_exists(const char* fusepath) {
        char* fullpath = myfs_file_full_path(fusepath);
        int result = (access(fullpath, F_OK) != -1) ? 1 : 0;
        free(fullpath);
     
        return result;
    }
    /* ---------------------------------------------------------------------------- */
     
    DIR* myfs_dir_open(char* dir_name) {
     
        return opendir(dir_name);
    }
    /* ---------------------------------------------------------------------------- */
     
    struct dirent* myfs_dir_read(DIR* d) {
     
        return readdir(d);
    }
    /* ---------------------------------------------------------------------------- */
     
    int myfs_dir_close(DIR* d) {
     
        return closedir(d) == 0;
    }
    /* ---------------------------------------------------------------------------- */
    myfs_tools.h
    #ifndef MYFS_TOOLS_H
    #define MYFS_TOOLS_H
    /* --------------------------------------------------- */
     
    #include <stdlib.h>
    #include <stdio.h>
    #include <sys/types.h>
    #include <dirent.h>
    #include <string.h>
    #include <errno.h>
    #include <unistd.h>
    #include <pwd.h>
    /* --------------------------------------------------- */
     
    /* Tworzy sciezke do plikow wykonywalnych dla obecnego uzytkownika */
    char* myfs_dir_get_fuse_home_path();
     
    /* Tworzy pelna sciezke do pliku wylonywalnego na podstawie sciezki fuse */
    char* myfs_file_full_path(const char* fusepath);
     
    /* Sprawdza czy plik okreslony przez sciezke fuse rzeczywiscie istnieje w normalnym systemie plikow */
    int myfs_file_exists(const char* fusepath);
     
    /* Owtiera katalog do czytania */
    DIR* myfs_dir_open(char* dir_name);
     
    /* Pobiera kolejna pozycje z katalogu do czytania */
    struct dirent* myfs_dir_read(DIR* d);
     
    /* Zamyka otwarty katalog */
    int myfs_dir_close(DIR* d);
    /* --------------------------------------------------- */
     
    #endif
  2. Skompiluj program poleceniem
    gcc myfs-main.c myfs_tools.c `pkg-config fuse --cflags --libs` -o fsname

    zastąp fsname swoją wymyśloną nazwą systemu plików.

  3. W swoim katalogu domowym stwórz folder fuse a w nim plik id:
    echo '#!/bin/bash' > ~/fuse/id
    echo 'id -u' >> ~/fuse/id
  4. Ustaw odpowiednie prawa dostępu:
    chmod 755 ~/fuse/id
  5. W miejscu gdzie tworzony jest plik wykonywalny programu stwórz folder, który będzie punktem montowania nowego systemu plików.
  6. Zamontuj swój system plików.
  7. Sprawdź zawartość katalogu - powinien pojawić się plik id.
  8. Spróbuj wyświetlić plik używając np. programu cat.
  9. Przeanalizuj program w celu zrozumienia otrzymanego rezultatu:
    1. Przy pomocy ls -l wyświetl zawartość katalogu, skąd się wzięły takie a nie inne atrybuty pliku (prawa dostępu, typ pliku, rozmiar, właściciel, itp.).
    2. Dlaczego wyświetlenie pliku powoduje uruchomienie skryptu? Gdzie to jest zdefiniowane w programie?
Ćwiczenie 2 - Wyświetlanie zawartości pliku
  1. W swoim katalogu domowym stwórz drugi skrypt:
    echo '#!/bin/bash' > ~/fuse/procesy
    echo 'ps uax' >> ~/fuse/procesy
  2. Ustaw prawa do wykonywania.
  3. Zamontuj swój system plików i przy jego pomocy spróbuj wyświetlić zawartość powyższych plików i odpowiedzieć na pytania:
    • Czy pliki wyświetla się poprawnie?
    • Dlaczego otrzymano taki efekt?
  4. :!: Spróbuj naprawić powstały błąd.
Ćwiczenie 3 - Atrybuty plików
  1. Rozbuduj program tak aby wyświetlał tylko pliki regularne: Użyj pola entry→d_type.
  2. Rozbuduj program tak aby pliki wykonywalne miały usunięty atrybut zapisu w. Użyj pola st_mode.
  3. Rozbuduj program tak aby efektem odczytu plików które możemy uruchomić był odczyt rezultatów ich działania natomiast plików niewykonywalnych (z naszego punktu widzenia) wyświetlenie ich treści. Użyj funkcji access, pread.
Ćwiczenie 4 - Zapis i odczyt do/z plików
  1. W swoim katalogu domowych stwórz plik o dowolnej nazwie, który posiada prawo do zapisu i jednocześnie nie posiada prawa do uruchamiania.
  2. Przy pomocy echo spróbuj coś zapisać do tego pliku za pośrednictwem swojego systemu pliku:
    echo test > nazwa_pliku
  3. Dlaczego wypisywany jest komunikat o braku dostępu (przecież masz prawo do zapisu) :?: :!:
  4. Jak pozbyć się tego komunikatu i sprawić, żeby w przypadku próby zapisu system informował o niezaimplementowanej funkcji fuse np.:
    myfs/plik: Nie zaimplementowana funkcja
  5. Rozbuduj program o możliwość zapisu do pliku:
    1. Dopisz implementację funkcji myfs_write. Prototyp funkcji znajdziesz tutaj.
    2. Dopisz nazwę funkcji w strukturze definiującej listę zaimplementowanych operacji.
    3. Napisz implementację funkcji. Użyj pwrite.
  6. Spróbuj ponownie zapisać do pliku jakieś dane:
    1. Czy teraz jest to możliwe?
    2. Na czym polega problem?
    3. Zaimplementuj brakującą funkcję.
  7. Ponownie wykonaj zapis:
    1. Użyj zwykłego przekierowania stdout na plik
      echo test > nazwa_pliku
    2. Użyj dopisywania do pliku
      echo test2 >> nazwa_pliku
    3. Sprawdź czy otrzymane rezultaty są poprawne.
    4. Jeżeli nie poszukaj błędu: sprawdź funkcję myfs_write czy użyta w niej funkcja open używa flagi O_APPEND.
  8. Jeżeli wszystko działa sprawdź czy (możesz użyć jakiegoś edytora tekstowego np. emacs):
    1. możesz czytać pliki wykonywalne → powinno być to możliwe, a rezultatem powinno być wyjście z procesu.
    2. możesz czytać pliki niewykonywalne z odjętym prawem do czytania → nie powinno być to możliwe, próba odczytu powinna skutkować komunikatem o braku dostępu.
    3. możesz czytać pliki niewykonywalne prawem do czytania → powinno być to możliwe.
    4. możesz zapisywać do plików wykonywalnych → nie powinno być to możliwe, próba zapisu powinna skutkować komunikatem o braku dostępu.
    5. możesz zapisywać do plików niewykonywalnych, z ustawionym prawem do zapisu → powinno być to możliwe.
    6. możesz zapisywać do plików niewykonywalnych, z odjętym prawem do zapisu → nie powinno być to możliwe, próba zapisu powinna skutkować komunikatem o braku dostępu.
  9. Jeżeli w którymkolwiek punkcie zachowanie twojego systemu plików jest inne popraw jego działanie.
Ćwiczenie 5 - Prawa dostępu
  1. Spróbuj zmienić prawa dostępu do jednego z plików np. id.
  2. Znowu pojawia się komunikat o niezaimplementowanej funkcji. Dodaj brakującą funkcję chmod:
    1. Jej prototyp znajdziesz tutaj.
    2. Do implementacji funkcji użyj funkcji systemowej chmod.
  3. Dodaj zaimplementowaną funkcję do listy dozwolonych operacji w systemie plików.
  4. Sprawdź działanie praw dostępu:
    1. Wyświetl plik wykonywalny.
    2. Zmień prawa dostępu
      chmod a-x plik
    3. Wyświetl plik ponownie.
  5. Jaki jest rezultat?
  6. Zmień wybrany plik zapisując do niego inne polecenie do wykonania:
    1. Zmodyfikuj wybrany plik przy pomocy edytora (np. emacs, pico, vi) lub przekierowania strumienia.
    2. Wyświetl plik wykonywalny → powinieneś otrzymać treść skryptu.
    3. Zmień prawa dostępu
      chmod a+x plik
    4. Wyświetl plik ponownie → powinieneś otrzymać rezultat działania skryptu.
  7. Gratulacje! Teraz przy pomocy swojego systemu plików możesz edytować skrypty i je uruchamiać :!: :-)
  8. Stwórz odpowiednie pliki, które pobierają wybrane informacje o sprzęcie, systemie operacyjnym, etc.

Ciekawe systemy plików

Poniżej przestawiona została mała (aczkolwiek interesująca) część różnych systemów plików napisanych w oparciu o FUSE:

  • SSHFS - pozwala zamontować zdalne drzewo katalogów za pośrednictwem ssh
  • GmailFS - wykorzystuje konto Gmail do składowania plików dowolnej wielkości. Napisany w Pythonie. Jego używanie jest niezgodne z zasadami użytkowania konta Gmail. Po zmianach w interfejsie usługi, przestał działać i być rozwijany. Istnieje jednak fork, który powinien działać.
  • TrueCrypt - popularne narzędzie do szyfrowania danych „w locie”.
  • BloggerFS - umożliwia zarządzanie wpisami na platforme Blogger.
  • FilterFS, rofs-filtered - umożliwiają montowanie lokalnego drzewa katalogów w innym miejscu, z filtrowaniem udostępnianych plików.
  • TSKmount-fuse - system oparty na urządzeniu blokowym. Pozwala na odzyskiwanie skasowanych plików z systemu plików istniejącego na urządzeniu (o ile typ systemu plików na to pozwala).
  • TagsFS - system plików organizowany przez tagi zamiast klasycznego drzewa katalogów.

Pełna lista systemów plików jest umieszczona na stronie wiki projektu oraz w Wikipedii.

Źródła

pl/dydaktyka/sitw/2015/lab5/start.txt · ostatnio zmienione: 2019/06/27 15:50 (edycja zewnętrzna)
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0