From 8627333558577356929556cdc5c328b2bdf1eb5e Mon Sep 17 00:00:00 2001 From: Daniel Markstedt Date: Sun, 19 Jul 2026 10:57:06 +0200 Subject: [PATCH] introduce Zeroconf AFP service discovery Add a provider-neutral discovery layer with native Avahi and DNS-SD backends, plus a stub backend so builds continue to work without Zeroconf dependencies. Normalize browse, resolve, snapshot, endpoint, and TXT data across providers. Expose bounded discovery through 'afp_client discover' with human-readable, verbose, and JSON output. Add a live service picker to afpcmd and support exact advertised service lookup for mounts, including authenticated volume listing. Browse companion _device-info._tcp advertisements alongside AFP services, associate matching records, and show their model TXT value in the MODEL column. Retain standalone device-info records in verbose output for diagnostics. Detect and select discovery providers in Meson, install the new utility and man page, and extend CI dependencies. Document the user and developer workflows and cover discovery events, output, service resolution, ambiguity, and picker behavior with deterministic fake-backend tests. Rework the afpcmd volume picker TUI to align with the new Zeroconf selection menu. --- .github/workflows/ci.yml | 10 + README.md | 52 +- TODO.md | 6 - cmdline/cmdline_afp.c | 209 +++++-- cmdline/cmdline_afp.h | 4 +- cmdline/cmdline_main.c | 78 ++- cmdline/discover.c | 475 +++++++++++++++ cmdline/discover.h | 10 + cmdline/meson.build | 9 +- discovery/backend.h | 39 ++ discovery/backend_avahi.c | 485 +++++++++++++++ discovery/backend_dnssd.c | 721 ++++++++++++++++++++++ discovery/backend_stub.c | 57 ++ discovery/client/discover.c | 844 ++++++++++++++++++++++++++ discovery/client/discover.h | 12 + discovery/discovery.c | 564 +++++++++++++++++ discovery/discovery.h | 87 +++ discovery/meson.build | 36 ++ discovery/service_types.h | 10 + docs/DEVELOPER.md | 51 ++ docs/FEATURES.md | 16 +- docs/GETTING_STARTED.md | 34 +- docs/manpages/afp_client.1 | 102 +++- docs/manpages/afpcmd.1 | 31 +- fuse/client.c | 182 +++++- fuse/meson.build | 6 +- meson.build | 85 +++ meson_options.txt | 13 + test/Dockerfile | 1 + test/check_include_boundaries.py | 1 + test/discovery_command_fake_backend.c | 139 +++++ test/discovery_fake_backend.c | 130 ++++ test/discovery_service_fake_backend.c | 109 ++++ test/meson.build | 86 +++ test/test_afpcmd_interactive.t | 40 +- test/test_cmdline_discover.c | 102 ++++ test/test_discover_command.c | 114 ++++ test/test_discovery.c | 56 ++ test/test_resolve_service.c | 22 + 39 files changed, 4921 insertions(+), 107 deletions(-) create mode 100644 cmdline/discover.c create mode 100644 cmdline/discover.h create mode 100644 discovery/backend.h create mode 100644 discovery/backend_avahi.c create mode 100644 discovery/backend_dnssd.c create mode 100644 discovery/backend_stub.c create mode 100644 discovery/client/discover.c create mode 100644 discovery/client/discover.h create mode 100644 discovery/discovery.c create mode 100644 discovery/discovery.h create mode 100644 discovery/meson.build create mode 100644 discovery/service_types.h create mode 100644 test/discovery_command_fake_backend.c create mode 100644 test/discovery_fake_backend.c create mode 100644 test/discovery_service_fake_backend.c create mode 100644 test/test_cmdline_discover.c create mode 100644 test/test_discover_command.c create mode 100644 test/test_discovery.c create mode 100644 test/test_resolve_service.c diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2d0cd974..bdbef8de 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,7 @@ jobs: - name: Install dependencies run: | apk add --no-cache \ + avahi-dev \ build-base \ fuse-dev \ libedit-dev \ @@ -75,6 +76,7 @@ jobs: - name: Install dependencies run: | apk add --no-cache \ + avahi-dev \ build-base \ fuse3-dev \ libedit-dev \ @@ -108,6 +110,7 @@ jobs: - name: Install dependencies run: | dnf --setopt=install_weak_deps=False --assumeyes install \ + avahi-devel \ fuse-devel \ gcc \ libgcrypt-devel \ @@ -141,6 +144,7 @@ jobs: - name: Install dependencies run: | dnf --setopt=install_weak_deps=False --assumeyes install \ + avahi-devel \ fuse3-devel \ gcc \ libgcrypt-devel \ @@ -174,6 +178,7 @@ jobs: sudo apt-get update sudo apt-get install --assume-yes --no-install-recommends \ gcc \ + libavahi-core-dev \ libbsd-dev \ libgcrypt-dev \ libfuse-dev \ @@ -209,6 +214,7 @@ jobs: sudo apt-get update sudo apt-get install --assume-yes --no-install-recommends \ gcc \ + libavahi-core-dev \ libgcrypt-dev \ libfuse3-dev \ libreadline-dev \ @@ -310,6 +316,7 @@ jobs: prepare: | pkg remove -y fusefs-libs3 pkg install -y \ + avahi \ fusefs-libs \ libedit \ libgcrypt \ @@ -346,6 +353,7 @@ jobs: sync: sshfs prepare: | pkg install -y \ + avahi \ fusefs-libs3 \ libedit \ libgcrypt \ @@ -411,6 +419,7 @@ jobs: sync: sshfs prepare: | pkg_add -I \ + avahi \ libgcrypt \ meson run: | @@ -453,6 +462,7 @@ jobs: tar -zxpf bootstrap.tar.gz -C / export PATH=/opt/local/sbin:/opt/local/bin:/usr/gnu/bin:/usr/bin:/usr/sbin:/sbin:$PATH pkgin -y install \ + avahi \ meson \ libgcrypt run: | diff --git a/README.md b/README.md index 538d6ac8..bcc4fb2e 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,22 @@ other applications that need to browse AFP shares. ### FUSE +Discover AFP servers advertised on the local network with Zeroconf: + + % afp_client discover + NAME TARGET PORT MODEL + Time Capsule Time-Capsule.local 548 TimeCapsule8,119 + afpserver afpserver.local 548 Macmini + +Mount an advertised service by its exact instance name. This is +non-interactive; use `afpcmd --browse` when you want a live picker: + + % afp_client mount --service "Office File Server" --user myuser \ + --volume "File Sharing" /home/myuser/fusemount + +Omit `--volume` to authenticate and list the volumes available to that user. +The trailing mountpoint may also be omitted because no filesystem is mounted. + Mount the *File Sharing* volume from afpserver.local on /home/myuser/fusemount authenticated as user *myuser* (you will be prompted for the password): @@ -52,9 +68,25 @@ colons, or other special characters are present. ### Command line client The *afpcmd* command line client allows you to interactively access AFP shares. -In the most basic use case, it takes an AFP URL as argument. +With `--browse`, it shows a live list of AFP services advertised through +Bonjour/DNS-SD or Avahi. Select a service number to connect, or `q` to quit. +The picker lists services only; pass an AFP URL directly when connecting to +a host that is not advertised. + + $ afpcmd --browse + Discovered AFP servers + + 1 Office File Server + 2 Time Capsule -Open volume File Sharing on afpserver.local: + q Quit + + Server: 1 + +After selection, `afpcmd` prompts for a username and password. + +You can also invoke it with an AFP URL directly, this example connects to +the *File Sharing* volume on *afpserver.local* as user *myuser*: $ afpcmd "afp://myuser@afpserver.local/File Sharing" Password: [input hidden] @@ -65,8 +97,13 @@ Connect anonymously to afpserver.local, list all volumes available to guest user $ afpcmd "afp://afpserver.local" Connected to server afpserver - Not attached to a volume. Run the 'ls' command to list available volumes - afpcmd: cd Dropbox + Available volumes on afpserver: + + 1 Dropbox + + q Quit + + afpcmd: 1 Attached to volume Dropbox afpcmd: ls -rw-r--r-- 6148 2025-07-11 14:09 .DS_Store @@ -106,9 +143,10 @@ library APIs, behavior, and supported features nevertheless exist. - To accommodate the above, the Stateless client controller daemon has been renamed to *afpsld*, reserving *afpfsd* for the FUSE controller daemon - *mount_afp* has been renamed to *mount_afpfs* to avoid namespace conflict with the macOS *mount_afp* command -- The *afpcmd* TUI client has to be invoked with an AFP URL, and the interactive **connect** command has been - removed; Conversely, when you execute the **disconnect** command the server session will be - terminated and *afpcmd* will shut down +- The *afpcmd* TUI client can browse Zeroconf-advertised AFP services. + The interactive **connect** command has been removed; conversely, + when you execute **disconnect**, the server session is terminated + and *afpcmd* shuts down rather than returning to the connect prompt - The *afpfsd* FUSE controller daemon has been rewritten to have one process per FUSE mount, with a manager process to handle mount requests and manage the afpfsd processes This allows for better isolation and stability of FUSE mounts compared to a single process handling all mounts. diff --git a/TODO.md b/TODO.md index af1f6713..ff6ff5c5 100644 --- a/TODO.md +++ b/TODO.md @@ -1,12 +1,6 @@ Netatalk Client Improvements ===================== -General client shortcomings ---------------------------- - -* integration with avahi/bonjour -* shutting down notices aren't honoured - FUSE client ----------- diff --git a/cmdline/cmdline_afp.c b/cmdline/cmdline_afp.c index 9a761813..472a2e33 100644 --- a/cmdline/cmdline_afp.c +++ b/cmdline/cmdline_afp.c @@ -156,6 +156,7 @@ static void cmdline_sanitize_text(const char *text, char *sanitized, static afpc_volume_t vol_id = NULL; static afpc_server_t server_id = NULL; static int connected = 0; +static int quit_requested = 0; static enum afp_metadata_mode transfer_metadata_mode = AFP_METADATA_AUTO; static int metadata_warning_emitted = 0; @@ -438,8 +439,23 @@ static int cmdline_getpass(void) (url.username[0] != '\0' && strcmp(url.username, "nobody") != 0 && url.password[0] == '\0')) { - passwd = getpass("Password:"); - strlcpy(url.password, passwd, AFPC_MAX_PASSWORD_LEN); + size_t password_len; + passwd = getpass("Password: "); + + if (!passwd) { + return -1; + } + + password_len = strlen(passwd); + + if (password_len >= sizeof(url.password)) { + cmdline_secure_clear(passwd, password_len); + fprintf(stderr, "Password is too long.\n"); + return -1; + } + + memcpy(url.password, passwd, password_len + 1); + cmdline_secure_clear(passwd, password_len); } return 0; @@ -953,7 +969,91 @@ static unsigned int directory_entries_per_screen(void) return (unsigned int)ws.ws_row - 1U; } -static void list_volumes(void) +static int attach_named_volume(const char *volume_name) +{ + unsigned int volume_options = AFP_SL_VOLUME_NO_LOCKING; + int ret; + + if (strlcpy(url.volumename, volume_name, sizeof(url.volumename)) + >= sizeof(url.volumename)) { + printf("Volume name is too long\n"); + return -1; + } + + ret = attach_volume_with_password_prompt(server_id, &vol_id, + volume_options); + + if (ret != 0 + && is_recoverable_session_error(ret) + && recover_session(0, 0) == 0) { + ret = attach_volume_with_password_prompt(server_id, &vol_id, + volume_options); + } + + if (ret != 0) { + if (ret == -EACCES) { + printf("Could not attach to volume %s: authentication failed\n", + url.volumename); + } else if (ret == -ENODEV) { + printf("Volume %s does not exist on this server\n", + url.volumename); + } else { + printf("Could not attach to volume %s\n", url.volumename); + } + + url.volumename[0] = '\0'; + return -1; + } + + printf("Attached to volume %s\n", url.volumename); + snprintf(curdir, sizeof(curdir), "/"); + return 0; +} + +static int parse_volume_slot(const char *line, unsigned int numvols, + unsigned int *index) +{ + char *end; + const char *start = line; + unsigned long slot; + + while (isspace((unsigned char) * start)) { + start++; + } + + if (!isdigit((unsigned char) * start)) { + return -EINVAL; + } + + errno = 0; + slot = strtoul(start, &end, 10); + + while (isspace((unsigned char) * end)) { + end++; + } + + if (errno != 0 || end == start || *end != '\0' + || slot == 0 || slot > numvols) { + return -EINVAL; + } + + *index = (unsigned int)slot - 1; + return 0; +} + +static void render_volume_picker(const struct afpc_volume_info *vols, + unsigned int numvols) +{ + printf("\nAvailable volumes on %s:\n\n", url.servername); + + for (unsigned int i = 0; i < numvols; i++) { + printf(" %u %s\n", i + 1, vols[i].volume_name_printable); + } + + puts("\n q Quit\n"); +} + +static int pick_volume(void) { unsigned int numvols = 0; struct afpc_volume_info *vols; @@ -963,7 +1063,7 @@ static void list_volumes(void) if (!vols) { printf("Out of memory\n"); - return; + return -1; } ret = afp_sl_getvols(server_id, &url, 0, count, &numvols, vols); @@ -974,17 +1074,50 @@ static void list_volumes(void) ret = afp_sl_getvols(server_id, &url, 0, count, &numvols, vols); } - if (ret == 0) { - printf("Available volumes on %s:\n", url.servername); + if (ret != 0) { + printf("Could not list volumes\n"); + free(vols); + return -1; + } + + if (numvols == 0) { + printf("\nAvailable volumes on %s:\n\n", url.servername); + puts(" No volumes available."); + free(vols); + return 0; + } + + for (;;) { + char *line; + unsigned int index; + render_volume_picker(vols, numvols); + line = readline("afpcmd: "); + + if (!line || strcmp(line, "q") == 0) { + free(line); + quit_requested = 1; + cmdline_afp_exit(); + ret = 0; + break; + } - for (unsigned int i = 0; i < numvols; i++) { - printf(" %s\n", vols[i].volume_name_printable); + if (parse_volume_slot(line, numvols, &index) != 0) { + fputs("Choose a listed volume number, or q to quit.\n", + stderr); + free(line); + continue; + } + + free(line); + ret = attach_named_volume(vols[index].volume_name_printable); + + if (ret == 0) { + break; } - } else { - printf("Could not list volumes\n"); } free(vols); + return ret; } int com_pass(char *unused) @@ -1112,7 +1245,7 @@ int com_dir(char * arg) if (!vol_id) { if (connected) { - list_volumes(); + pick_volume(); return 0; } @@ -3376,7 +3509,7 @@ int com_cd(char *arg) snprintf(curdir, AFPC_MAX_PATH, "/"); show_dir = 1; } else { - list_volumes(); + pick_volume(); } goto out; @@ -3394,7 +3527,7 @@ int com_cd(char *arg) snprintf(curdir, AFPC_MAX_PATH, "/"); show_dir = 1; } else { - list_volumes(); + pick_volume(); } goto out; @@ -3407,34 +3540,12 @@ int com_cd(char *arg) if (vol_id == NULL) { /* Not attached to a volume, treat arg as volume name */ - strlcpy(url.volumename, path, AFPC_VOLUME_NAME_UTF8_LEN); - unsigned int volume_options = AFP_SL_VOLUME_NO_LOCKING; - ret = attach_volume_with_password_prompt(server_id, &vol_id, - volume_options); - - if (ret != 0 - && is_recoverable_session_error(ret) && recover_session(0, 0) == 0) { - ret = attach_volume_with_password_prompt(server_id, &vol_id, - volume_options); - } + ret = attach_named_volume(path); if (ret != 0) { - if (ret == -EACCES) { - printf("Could not attach to volume %s: authentication failed\n", - url.volumename); - } else if (ret == -ENODEV) { - printf("Volume %s does not exist on this server\n", path); - } else { - printf("Could not attach to volume %s\n", url.volumename); - } - - url.volumename[0] = '\0'; - ret = -1; goto error; } - printf("Attached to volume %s\n", url.volumename); - snprintf(curdir, AFPC_MAX_PATH, "/"); goto out; } @@ -3550,7 +3661,7 @@ int com_exit(char *unused) } snprintf(curdir, AFPC_MAX_PATH, "/"); - return 0; + return pick_volume(); } /* Print out the current working directory locally. */ @@ -3690,8 +3801,8 @@ static int cmdline_server_startup(int batch_mode) return -1; } } - } else { - printf("Use 'ls' to list available volumes, 'cd' to attach to a volume\n"); + } else if (!batch_mode && pick_volume() != 0) { + return -1; } return 0; @@ -3915,6 +4026,11 @@ void cmdline_afp_exit(void) connected = 0; } +int cmdline_afp_quit_requested(void) +{ + return quit_requested; +} + void cmdline_afp_setup_logging(void) { openlog("afpcmd", LOG_PID | LOG_CONS, LOG_USER); @@ -3923,7 +4039,8 @@ void cmdline_afp_setup_logging(void) } -int cmdline_afp_setup(int batch_mode, char * url_string) +int cmdline_afp_setup(int batch_mode, char *url_string, + const char *username_override) { snprintf(curdir, AFPC_MAX_PATH, "%s", DEFAULT_DIRECTORY); memset(connect_servername, 0, sizeof(connect_servername)); @@ -3943,13 +4060,23 @@ int cmdline_afp_setup(int batch_mode, char * url_string) return -1; } + if (username_override + && strlcpy(url.username, username_override, + sizeof(url.username)) >= sizeof(url.username)) { + fprintf(stderr, "Username is too long.\n"); + return -1; + } + /* If no username was specified in URL, use AFP guest user */ if (url.username[0] == '\0') { strlcpy(url.username, "nobody", AFPC_MAX_USERNAME_LEN); } strlcpy(connect_servername, url.servername, sizeof(connect_servername)); - cmdline_getpass(); + + if (cmdline_getpass() != 0) { + return -1; + } if (cmdline_server_startup(batch_mode) != 0) { return -1; diff --git a/cmdline/cmdline_afp.h b/cmdline/cmdline_afp.h index 9e177020..95c51422 100644 --- a/cmdline/cmdline_afp.h +++ b/cmdline/cmdline_afp.h @@ -28,7 +28,9 @@ int com_disconnect(char *unused); int com_exit(char *unused); void cmdline_afp_exit(void); -int cmdline_afp_setup(int batch_mode, char * url_string); +int cmdline_afp_quit_requested(void); +int cmdline_afp_setup(int batch_mode, char *url_string, + const char *username_override); void cmdline_afp_setup_logging(void); void cmdline_set_log_level(int loglevel); void cmdline_set_verbose(int verbose); diff --git a/cmdline/cmdline_main.c b/cmdline/cmdline_main.c index cb2c07bb..b8430c5e 100644 --- a/cmdline/cmdline_main.c +++ b/cmdline/cmdline_main.c @@ -25,6 +25,7 @@ 59 Temple Place, Suite 330, Boston, MA 02111 USA. */ +#include #include #include #include @@ -50,6 +51,7 @@ #include "netatalk-client/url.h" #include "cmdline_afp.h" +#include "discover.h" static int running = 1; @@ -407,7 +409,7 @@ void *cmdline_ui(void *other) char *line; char *s, s2[ARG_LEN]; - while (running) { + while (running && !cmdline_afp_quit_requested()) { line = readline("afpcmd: "); if (!line) { @@ -468,9 +470,12 @@ static void usage(void) { printf( "Netatalk Client %s - AFP command-line client\n" - "afpcmd [-h] [-V] [-v loglevel] [-M mode] \n" + "afpcmd [-V] [-v loglevel] [-M mode] --browse\n" + "afpcmd [-V] [-v loglevel] [-M mode] \n" + "afpcmd -h\n" "Options:\n" "\t-h: show this help message and exit\n" + "\t-b, --browse: browse and select an advertised AFP service\n" "\t-M mode: preserve metadata using auto, netatalk, xattr, macos, or none\n" "\t-r: recursively transfer directories in batch mode\n" "\t-V: verbose mode (show detailed transfer messages)\n" @@ -493,10 +498,13 @@ int main(int argc, char *argv[]) int c; int recursive = 0; int verbose = 0; + int browse = 0; + int discovered = 0; int show_usage = 0; int log_level = LOG_NOTICE; const char *metadata_mode = "auto"; struct option long_options[] = { + {"browse", 0, 0, 'b'}, {"help", 0, 0, 'h'}, {"metadata", 1, 0, 'M'}, {"recursive", 0, 0, 'r'}, @@ -505,12 +513,14 @@ int main(int argc, char *argv[]) {NULL, 0, NULL, 0}, }; char *url = NULL; + char discovered_url[AFPC_MAX_PATH]; + char *discovered_username = NULL; char *local_path = NULL; int batch_mode = 0; int direction = 0; /* 0 = GET (remote->local), 1 = PUT (local->remote) */ while (1) { - c = getopt_long(argc, argv, "hM:rVv:", + c = getopt_long(argc, argv, "bhM:rVv:", long_options, &option_index); if (c == -1) { @@ -518,6 +528,10 @@ int main(int argc, char *argv[]) } switch (c) { + case 'b': + browse = 1; + break; + case 'h': show_usage = 1; break; @@ -568,8 +582,14 @@ int main(int argc, char *argv[]) return 1; } + if (browse && optind < argc) { + printf("--browse cannot be combined with an AFP URL or batch arguments.\n"); + usage(); + exit(1); + } + /* Check arguments for batch mode */ - if (argc - optind == 2) { + if (!browse && argc - optind == 2) { struct afpc_url tmp_url; char *arg1 = argv[optind]; char *arg2 = argv[optind + 1]; @@ -592,13 +612,36 @@ int main(int argc, char *argv[]) usage(); exit(1); } - } else if (optind < argc) { + } else if (!browse && optind < argc) { url = argv[optind]; } if (!url) { - usage(); - exit(1); + if (!browse) { + usage(); + exit(1); + } + + int discover_ret = cmdline_discover_url(discovered_url, + sizeof(discovered_url)); + + if (discover_ret > 0) { + exit(0); + } + + if (discover_ret < 0) { + if (discover_ret == -ENOTSUP) { + fputs("Zeroconf discovery support was not built.\n", stderr); + } else { + fprintf(stderr, "Could not browse AFP services: %s\n", + strerror(-discover_ret)); + } + + exit(1); + } + + url = discovered_url; + discovered = 1; } if (recursive && !batch_mode) { @@ -611,12 +654,31 @@ int main(int argc, char *argv[]) tcgetattr(STDIN_FILENO, &save_termios); initialize_readline(); - if (cmdline_afp_setup(batch_mode, url) != 0) { + if (discovered) { + discovered_username = readline("Username (blank for guest): "); + + if (!discovered_username) { + tty_reset(STDIN_FILENO); + exit(0); + } + + if (strnlen(discovered_username, AFPC_MAX_USERNAME_LEN) + >= AFPC_MAX_USERNAME_LEN) { + fprintf(stderr, "Username is too long.\n"); + free(discovered_username); + tty_reset(STDIN_FILENO); + exit(1); + } + } + + if (cmdline_afp_setup(batch_mode, url, discovered_username) != 0) { + free(discovered_username); cmdline_afp_exit(); tty_reset(STDIN_FILENO); exit(1); } + free(discovered_username); signal(SIGINT, earlyexit_handler); if (batch_mode) { diff --git a/cmdline/discover.c b/cmdline/discover.c new file mode 100644 index 00000000..fb03f27c --- /dev/null +++ b/cmdline/discover.c @@ -0,0 +1,475 @@ +/* + * Copyright (C) 2026 Daniel Markstedt + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "discovery/discovery.h" + +#include "discover.h" + +#define PICKER_INTERVAL_MS 100 +#define PICKER_RESOLVE_TIMEOUT_MS 3000 + +struct picker_member { + struct afpc_discovery_service service; + int active; +}; + +struct picker_entry { + struct afpc_discovery_service service; + struct picker_member *members; + size_t member_count; + size_t member_capacity; + unsigned int slot; +}; + +struct picker { + struct picker_entry *entries; + size_t count; + size_t capacity; + unsigned int next_slot; +}; + +static int service_group_equal(const struct afpc_discovery_service *left, + const struct afpc_discovery_service *right) +{ + return strcmp(left->instance, right->instance) == 0 + && strcmp(left->type, right->type) == 0 + && strcmp(left->domain, right->domain) == 0; +} + +static int picker_entry_active(const struct picker_entry *entry) +{ + for (size_t i = 0; i < entry->member_count; i++) { + if (entry->members[i].active) { + return 1; + } + } + + return 0; +} + +static struct picker_entry *find_group( + struct picker *picker, const struct afpc_discovery_service *service) +{ + for (size_t i = 0; i < picker->count; i++) { + if (service_group_equal(&picker->entries[i].service, service)) { + return &picker->entries[i]; + } + } + + return NULL; +} + +static struct picker_entry *find_slot(struct picker *picker, + unsigned long slot) +{ + for (size_t i = 0; i < picker->count; i++) { + if (picker->entries[i].slot == slot + && picker_entry_active(&picker->entries[i])) { + return &picker->entries[i]; + } + } + + return NULL; +} + +static struct picker_member *find_member( + struct picker_entry *entry, + const struct afpc_discovery_service *service) +{ + for (size_t i = 0; i < entry->member_count; i++) { + if (entry->members[i].service.interface_index + == service->interface_index) { + return &entry->members[i]; + } + } + + return NULL; +} + +static int add_member(struct picker_entry *entry, + const struct afpc_discovery_service *service) +{ + struct picker_member *members; + size_t capacity; + + if (entry->member_count == entry->member_capacity) { + capacity = entry->member_capacity == 0 + ? 4 : entry->member_capacity * 2; + members = realloc(entry->members, capacity * sizeof(*members)); + + if (!members) { + return -ENOMEM; + } + + entry->members = members; + entry->member_capacity = capacity; + } + + entry->members[entry->member_count].service = *service; + entry->members[entry->member_count].active = 1; + entry->member_count++; + return 1; +} + +static int update_picker(struct picker *picker, + const struct afpc_discovery_event *event) +{ + struct picker_entry *entry = find_group(picker, &event->service); + struct picker_member *member = entry + ? find_member(entry, &event->service) : NULL; + + if (event->type == AFPC_DISCOVERY_EVENT_REMOVE) { + if (member && member->active) { + member->active = 0; + return 1; + } + + return 0; + } + + if (event->type != AFPC_DISCOVERY_EVENT_ADD + && event->type != AFPC_DISCOVERY_EVENT_UPDATE) { + return 0; + } + + if (member) { + if (!member->active) { + member->active = 1; + member->service = event->service; + return 1; + } + + member->service = event->service; + return event->type == AFPC_DISCOVERY_EVENT_UPDATE; + } + + if (entry) { + return add_member(entry, &event->service); + } + + if (picker->count == picker->capacity) { + struct picker_entry *entries; + size_t capacity = picker->capacity == 0 + ? 8 : picker->capacity * 2; + entries = realloc(picker->entries, capacity * sizeof(*entries)); + + if (!entries) { + return -ENOMEM; + } + + picker->entries = entries; + picker->capacity = capacity; + } + + entry = &picker->entries[picker->count++]; + memset(entry, 0, sizeof(*entry)); + entry->service = event->service; + entry->slot = picker->next_slot++; + return add_member(entry, &event->service); +} + +static int has_duplicate_name(const struct picker *picker, size_t index) +{ + for (size_t i = 0; i < picker->count; i++) { + if (i != index && picker_entry_active(&picker->entries[i]) + && strcmp(picker->entries[i].service.instance, + picker->entries[index].service.instance) == 0) { + return 1; + } + } + + return 0; +} + +static void render_picker(const struct picker *picker) +{ + puts("Discovered AFP servers\n"); + + for (size_t i = 0; i < picker->count; i++) { + const struct picker_entry *entry = &picker->entries[i]; + + if (!picker_entry_active(entry)) { + continue; + } + + printf(" %u %s", entry->slot, entry->service.instance); + + if (has_duplicate_name(picker, i)) { + printf(" [%s]", entry->service.domain); + } + + putchar('\n'); + } + + puts("\n q Quit\n"); + fputs("afpcmd: ", stdout); + fflush(stdout); +} + +static int drain_events(struct afpc_discovery *discovery, + struct picker *picker, int *changed) +{ + struct afpc_discovery_event event; + int ret; + + while ((ret = afpc_discovery_next(discovery, &event, 0)) > 0) { + if (event.type == AFPC_DISCOVERY_EVENT_ERROR) { + if (event.message[0] != '\0') { + fprintf(stderr, "Discovery failed: %s\n", event.message); + } + + if (event.error == 0) { + return -EIO; + } + + return event.error > 0 ? -event.error : event.error; + } + + if (strcasecmp(event.service.type, + AFPC_DISCOVERY_AFP_SERVICE_TYPE) != 0) { + continue; + } + + ret = update_picker(picker, &event); + + if (ret < 0) { + return ret; + } + + *changed |= ret; + } + + return ret; +} + +static int endpoint_score(const struct afpc_discovery_endpoint *endpoint) +{ + if (endpoint->address_len == 0) { + return endpoint->target[0] == '\0' ? -1 : 1; + } + + if (endpoint->address.ss_family == AF_INET) { + const struct sockaddr_in *address = + (const struct sockaddr_in *)&endpoint->address; + uint32_t value = ntohl(address->sin_addr.s_addr); + return (value & 0xff000000U) == 0x7f000000U ? 30 : 130; + } + + if (endpoint->address.ss_family == AF_INET6) { + const struct sockaddr_in6 *address = + (const struct sockaddr_in6 *)&endpoint->address; + return IN6_IS_ADDR_LOOPBACK(&address->sin6_addr) ? 20 : 120; + } + + return -1; +} + +static int make_service_url(struct afpc_discovery *discovery, + const struct picker_entry *entry, + char *url, size_t url_size) +{ + char best_host[AFPC_DISCOVERY_TARGET_LEN + IF_NAMESIZE + 2] = {0}; + char resolved_target[AFPC_DISCOVERY_TARGET_LEN] = {0}; + uint16_t resolved_port = 0; + int best_score = -1; + int last_error = -EHOSTUNREACH; + + for (size_t i = 0; i < entry->member_count; i++) { + struct afpc_discovery_endpoint *endpoints = NULL; + size_t endpoint_count = 0; + int ret; + + if (!entry->members[i].active) { + continue; + } + + ret = afpc_discovery_resolve(discovery, + &entry->members[i].service, + &endpoints, &endpoint_count, + PICKER_RESOLVE_TIMEOUT_MS); + + if (ret != 0) { + last_error = ret; + afpc_discovery_free_endpoints(&endpoints, endpoint_count); + continue; + } + + for (size_t j = 0; j < endpoint_count; j++) { + char endpoint_host[AFPC_DISCOVERY_TARGET_LEN + IF_NAMESIZE + 2]; + int score; + + if (resolved_port == 0) { + snprintf(resolved_target, sizeof(resolved_target), "%s", + endpoints[j].target); + resolved_port = endpoints[j].port; + } else if (resolved_port != endpoints[j].port + || strcasecmp(resolved_target, + endpoints[j].target) != 0) { + afpc_discovery_free_endpoints(&endpoints, endpoint_count); + return -EEXIST; + } + + score = endpoint_score(&endpoints[j]); + + if (score <= best_score + || afpc_discovery_endpoint_host(&endpoints[j], + endpoint_host, + sizeof(endpoint_host)) != 0) { + continue; + } + + memcpy(best_host, endpoint_host, strlen(endpoint_host) + 1); + best_score = score; + } + + afpc_discovery_free_endpoints(&endpoints, endpoint_count); + } + + if (best_score < 0 || resolved_port == 0) { + return last_error; + } + + int length; + + if (strchr(best_host, ':')) { + length = snprintf(url, url_size, "afp://[%s]:%u", best_host, + resolved_port); + } else { + length = snprintf(url, url_size, "afp://%s:%u", best_host, + resolved_port); + } + + return length < 0 || (size_t)length >= url_size + ? -ENAMETOOLONG : 0; +} + +static int parse_slot(char *line, unsigned long *slot) +{ + char *end; + unsigned long value; + errno = 0; + value = strtoul(line, &end, 10); + + while (*end == ' ' || *end == '\t' || *end == '\r' || *end == '\n') { + end++; + } + + if (errno != 0 || end == line || *end != '\0' || value == 0) { + return -EINVAL; + } + + *slot = value; + return 0; +} + +int cmdline_discover_url(char *url, size_t url_size) +{ + struct afpc_discovery *discovery = NULL; + struct picker picker = { .next_slot = 1 }; + char line[64]; + int changed = 0; + int ret; + + if (!url || url_size == 0) { + return -EINVAL; + } + + ret = afpc_discovery_open(&discovery, NULL); + + if (ret != 0) { + return ret; + } + + for (;;) { + fd_set read_fds; + struct timeval timeout = { + .tv_sec = 0, + .tv_usec = PICKER_INTERVAL_MS * 1000, + }; + unsigned long slot; + struct picker_entry *entry; + ret = drain_events(discovery, &picker, &changed); + + if (ret < 0) { + break; + } + + if (changed) { + render_picker(&picker); + changed = 0; + } + + FD_ZERO(&read_fds); + FD_SET(STDIN_FILENO, &read_fds); + ret = select(STDIN_FILENO + 1, &read_fds, NULL, NULL, &timeout); + + if (ret < 0) { + if (errno == EINTR) { + continue; + } + + ret = -errno; + break; + } + + if (ret == 0 || !FD_ISSET(STDIN_FILENO, &read_fds)) { + continue; + } + + if (!fgets(line, sizeof(line), stdin) + || line[0] == 'q' || line[0] == 'Q') { + ret = 1; + break; + } + + if (parse_slot(line, &slot) != 0 + || !(entry = find_slot(&picker, slot))) { + fputs("Choose a listed service number, or q to quit.\n", + stderr); + render_picker(&picker); + continue; + } + + ret = make_service_url(discovery, entry, url, url_size); + + if (ret == 0) { + break; + } + + if (ret == -EEXIST) { + fprintf(stderr, + "Could not resolve %s: advertisements disagree on " + "target or port\n", + entry->service.instance); + } else { + fprintf(stderr, "Could not resolve %s: %s\n", + entry->service.instance, strerror(-ret)); + } + + changed = 1; + } + + for (size_t i = 0; i < picker.count; i++) { + free(picker.entries[i].members); + } + + free(picker.entries); + afpc_discovery_close(&discovery); + return ret; +} diff --git a/cmdline/discover.h b/cmdline/discover.h new file mode 100644 index 00000000..6c36d075 --- /dev/null +++ b/cmdline/discover.h @@ -0,0 +1,10 @@ +#ifndef NETATALK_CLIENT_CMDLINE_DISCOVER_H +#define NETATALK_CLIENT_CMDLINE_DISCOVER_H + +#include + +/* Returns zero after selecting a service, one when the user quits, or a + * negative errno value when discovery cannot continue. */ +int cmdline_discover_url(char *url, size_t url_size); + +#endif diff --git a/cmdline/meson.build b/cmdline/meson.build index 644a953f..a204329d 100644 --- a/cmdline/meson.build +++ b/cmdline/meson.build @@ -12,15 +12,20 @@ executable( if with_afpcmd afpcmd_incdir = include_directories(['.', '../include', include_dirs]) - executable( + afpcmd_exe = executable( 'afpcmd', sources: [ 'cmdline_afp.c', 'cmdline_main.c', + 'discover.c', ], include_directories: afpcmd_incdir, link_with: libafpsl, - dependencies: [root_dependencies, cmdline_dependencies], + dependencies: [ + root_dependencies, + cmdline_dependencies, + discovery_dep, + ], c_args: cflags, install: true, install_rpath: install_runtime_path, diff --git a/discovery/backend.h b/discovery/backend.h new file mode 100644 index 00000000..8448049b --- /dev/null +++ b/discovery/backend.h @@ -0,0 +1,39 @@ +#ifndef NETATALK_CLIENT_DISCOVERY_BACKEND_H +#define NETATALK_CLIENT_DISCOVERY_BACKEND_H + +#include "discovery.h" + +enum afpc_discovery_source { + AFPC_DISCOVERY_SOURCE_UNSPEC = 1U << 0, + AFPC_DISCOVERY_SOURCE_IPV4 = 1U << 1, + AFPC_DISCOVERY_SOURCE_IPV6 = 1U << 2, +}; + +struct afpc_discovery_backend_event { + enum afpc_discovery_event_type type; + struct afpc_discovery_service service; + unsigned int source; + int error; + const char *message; +}; + +struct afpc_discovery_backend_ops { + const char *name; + int (*start)(void **context, struct afpc_discovery *discovery, + const struct afpc_discovery_options *options); + int (*iterate)(void *context, struct afpc_discovery *discovery, + int timeout_ms); + int (*resolve)(void *context, struct afpc_discovery *discovery, + const struct afpc_discovery_service *service, + struct afpc_discovery_endpoint **endpoints, + size_t *endpoint_count, int timeout_ms); + void (*stop)(void *context); +}; + +extern const struct afpc_discovery_backend_ops afpc_discovery_backend; + +int afpc_discovery_backend_emit( + struct afpc_discovery *discovery, + const struct afpc_discovery_backend_event *event); + +#endif diff --git a/discovery/backend_avahi.c b/discovery/backend_avahi.c new file mode 100644 index 00000000..dd1615f4 --- /dev/null +++ b/discovery/backend_avahi.c @@ -0,0 +1,485 @@ +/* + * Copyright (C) 2026 Daniel Markstedt + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + */ + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "backend.h" + +struct avahi_context { + AvahiSimplePoll *simple_poll; + AvahiClient *client; + AvahiServiceBrowser *browsers[2]; + size_t browser_count; + struct afpc_discovery *discovery; + char *service_types[2]; + size_t service_type_count; + char *domain; + unsigned int interface_index; + int callback_error; +}; + +struct avahi_resolve_result { + int done; + int error; + struct afpc_discovery_endpoint endpoint; +}; + +static void free_service_types(struct avahi_context *context) +{ + for (size_t i = 0; i < context->service_type_count; i++) { + free(context->service_types[i]); + } +} + +static long long now_ms(void) +{ + struct timeval now; + + if (gettimeofday(&now, NULL) != 0) { + return 0; + } + + return (long long)now.tv_sec * 1000LL + now.tv_usec / 1000; +} + +static int copy_string(char *destination, size_t destination_size, + const char *source) +{ + size_t length; + + if (!destination || destination_size == 0 || !source) { + return -EINVAL; + } + + length = strlen(source); + + if (length >= destination_size) { + return -ENAMETOOLONG; + } + + memcpy(destination, source, length + 1); + return 0; +} + +static unsigned int source_for_protocol(AvahiProtocol protocol) +{ + if (protocol == AVAHI_PROTO_INET) { + return AFPC_DISCOVERY_SOURCE_IPV4; + } + + if (protocol == AVAHI_PROTO_INET6) { + return AFPC_DISCOVERY_SOURCE_IPV6; + } + + return AFPC_DISCOVERY_SOURCE_UNSPEC; +} + +static unsigned int interface_index_from_avahi(AvahiIfIndex interface) +{ + return interface < 0 ? 0U : (unsigned int)interface; +} + +static void emit_error(struct avahi_context *context, int error, + const char *message) +{ + struct afpc_discovery_backend_event event; + int ret; + memset(&event, 0, sizeof(event)); + event.type = AFPC_DISCOVERY_EVENT_ERROR; + event.error = error; + event.message = message; + ret = afpc_discovery_backend_emit(context->discovery, &event); + + if (ret != 0) { + context->callback_error = ret; + } +} + +static void browser_callback(AvahiServiceBrowser *browser, + AvahiIfIndex interface, + AvahiProtocol protocol, + AvahiBrowserEvent browser_event, + const char *name, const char *type, + const char *domain, + AvahiLookupResultFlags flags, void *userdata) +{ + struct avahi_context *context = userdata; + struct afpc_discovery_backend_event event; + int ret; + (void)browser; + (void)flags; + + if (browser_event == AVAHI_BROWSER_FAILURE) { + emit_error(context, EIO, "Avahi service browser failed"); + return; + } + + if (browser_event != AVAHI_BROWSER_NEW + && browser_event != AVAHI_BROWSER_REMOVE) { + return; + } + + memset(&event, 0, sizeof(event)); + event.type = browser_event == AVAHI_BROWSER_NEW + ? AFPC_DISCOVERY_EVENT_ADD : AFPC_DISCOVERY_EVENT_REMOVE; + event.source = source_for_protocol(protocol); + event.service.interface_index = interface_index_from_avahi(interface); + + if (copy_string(event.service.instance, + sizeof(event.service.instance), name) != 0 + || copy_string(event.service.type, sizeof(event.service.type), + type) != 0 + || copy_string(event.service.domain, + sizeof(event.service.domain), domain) != 0) { + context->callback_error = -ENAMETOOLONG; + return; + } + + ret = afpc_discovery_backend_emit(context->discovery, &event); + + if (ret != 0) { + context->callback_error = ret; + } +} + +static int create_browser(struct avahi_context *context, + AvahiClient *client) +{ + AvahiIfIndex interface = AVAHI_IF_UNSPEC; + + if (context->browser_count != 0) { + return 0; + } + + if (context->interface_index != 0) { + interface = (AvahiIfIndex)context->interface_index; + } + + for (size_t i = 0; i < context->service_type_count; i++) { + context->browsers[i] = avahi_service_browser_new( + client, interface, AVAHI_PROTO_UNSPEC, + context->service_types[i], context->domain, + 0, browser_callback, context); + + if (!context->browsers[i]) { + for (size_t j = 0; j < i; j++) { + avahi_service_browser_free(context->browsers[j]); + context->browsers[j] = NULL; + } + + context->browser_count = 0; + return -EIO; + } + + context->browser_count++; + } + + return 0; +} + +static void client_callback(AvahiClient *client, AvahiClientState state, + void *userdata) +{ + struct avahi_context *context = userdata; + context->client = client; + + if (state == AVAHI_CLIENT_S_RUNNING) { + if (create_browser(context, client) != 0) { + emit_error(context, EIO, + "Could not create Avahi service browser"); + } + } else if (state == AVAHI_CLIENT_FAILURE) { + emit_error(context, EIO, "Avahi client connection failed"); + } +} + +static int avahi_start(void **context_ptr, + struct afpc_discovery *discovery, + const struct afpc_discovery_options *options) +{ + struct avahi_context *context; + const char *primary_service_type; + int error; + + if (!context_ptr || !discovery || !options) { + return -EINVAL; + } + + context = calloc(1, sizeof(*context)); + + if (!context) { + return -ENOMEM; + } + + primary_service_type = options->service_type + ? options->service_type + : AFPC_DISCOVERY_AFP_SERVICE_TYPE; + context->service_types[0] = strdup(primary_service_type); + context->service_type_count = 1; + + if (!options->service_type) { + context->service_types[context->service_type_count++] = + strdup(AFPC_DISCOVERY_DEVICE_INFO_SERVICE_TYPE); + } + + context->domain = options->domain ? strdup(options->domain) : NULL; + context->interface_index = options->interface_index; + context->discovery = discovery; + + if (!context->service_types[0] + || (context->service_type_count > 1 + && !context->service_types[1]) + || (options->domain && !context->domain)) { + free(context->domain); + free_service_types(context); + free(context); + return -ENOMEM; + } + + context->simple_poll = avahi_simple_poll_new(); + + if (!context->simple_poll) { + free(context->domain); + free_service_types(context); + free(context); + return -ENOMEM; + } + + context->client = avahi_client_new( + avahi_simple_poll_get(context->simple_poll), + AVAHI_CLIENT_NO_FAIL, client_callback, context, + &error); + + if (!context->client) { + avahi_simple_poll_free(context->simple_poll); + free(context->domain); + free_service_types(context); + free(context); + return -EIO; + } + + *context_ptr = context; + return 0; +} + +static int avahi_iterate(void *context_ptr, + struct afpc_discovery *discovery, int timeout_ms) +{ + struct avahi_context *context = context_ptr; + int ret; + (void)discovery; + + if (!context) { + return -EINVAL; + } + + context->callback_error = 0; + ret = avahi_simple_poll_iterate(context->simple_poll, timeout_ms); + + if (context->callback_error != 0) { + return context->callback_error; + } + + return ret < 0 ? -EIO : ret; +} + +static void resolver_callback(AvahiServiceResolver *resolver, + AvahiIfIndex interface, + AvahiProtocol protocol, + AvahiResolverEvent resolver_event, + const char *name, const char *type, + const char *domain, const char *host_name, + const AvahiAddress *address, uint16_t port, + AvahiStringList *txt, + AvahiLookupResultFlags flags, void *userdata) +{ + struct avahi_resolve_result *result = userdata; + struct sockaddr_in *address4; + struct sockaddr_in6 *address6; + char numeric_address[AVAHI_ADDRESS_STR_MAX]; + unsigned int interface_index; + size_t txt_len; + (void)resolver; + (void)name; + (void)type; + (void)domain; + (void)flags; + result->done = 1; + + if (resolver_event != AVAHI_RESOLVER_FOUND) { + result->error = -EIO; + return; + } + + result->error = copy_string(result->endpoint.target, + sizeof(result->endpoint.target), host_name); + + if (result->error != 0) { + return; + } + + interface_index = interface_index_from_avahi(interface); + result->endpoint.port = port; + result->endpoint.interface_index = interface_index; + avahi_address_snprint(numeric_address, sizeof(numeric_address), address); + + if (protocol == AVAHI_PROTO_INET) { + address4 = (struct sockaddr_in *)&result->endpoint.address; + address4->sin_family = AF_INET; + address4->sin_port = htons(port); + + if (inet_pton(AF_INET, numeric_address, &address4->sin_addr) != 1) { + result->error = -EIO; + return; + } + + result->endpoint.address_len = sizeof(*address4); + } else if (protocol == AVAHI_PROTO_INET6) { + address6 = (struct sockaddr_in6 *)&result->endpoint.address; + address6->sin6_family = AF_INET6; + address6->sin6_port = htons(port); + + if (inet_pton(AF_INET6, numeric_address, &address6->sin6_addr) != 1) { + result->error = -EIO; + return; + } + + if (IN6_IS_ADDR_LINKLOCAL(&address6->sin6_addr) + && interface_index != 0) { + address6->sin6_scope_id = interface_index; + } + + result->endpoint.address_len = sizeof(*address6); + } + + txt_len = avahi_string_list_serialize(txt, NULL, 0); + + if (txt_len != 0) { + result->endpoint.txt = malloc(txt_len); + + if (!result->endpoint.txt) { + result->error = -ENOMEM; + return; + } + + result->endpoint.txt_len = avahi_string_list_serialize( + txt, result->endpoint.txt, txt_len); + } +} + +static int avahi_resolve(void *context_ptr, + struct afpc_discovery *discovery, + const struct afpc_discovery_service *service, + struct afpc_discovery_endpoint **endpoints, + size_t *endpoint_count, int timeout_ms) +{ + struct avahi_context *context = context_ptr; + struct avahi_resolve_result result; + AvahiServiceResolver *resolver; + AvahiIfIndex interface; + long long deadline; + int remaining; + int ret; + (void)discovery; + memset(&result, 0, sizeof(result)); + interface = service->interface_index == 0 + ? AVAHI_IF_UNSPEC : (AvahiIfIndex)service->interface_index; + resolver = avahi_service_resolver_new( + context->client, interface, AVAHI_PROTO_UNSPEC, + service->instance, service->type, service->domain, + AVAHI_PROTO_UNSPEC, 0, resolver_callback, &result); + + if (!resolver) { + return -EIO; + } + + deadline = timeout_ms < 0 ? 0 : now_ms() + timeout_ms; + + while (!result.done) { + remaining = timeout_ms < 0 ? -1 : (int)(deadline - now_ms()); + + if (timeout_ms >= 0 && remaining <= 0) { + break; + } + + ret = avahi_simple_poll_iterate(context->simple_poll, remaining); + + if (ret < 0) { + result.error = -EIO; + break; + } + } + + avahi_service_resolver_free(resolver); + + if (!result.done && result.error == 0) { + return -ETIMEDOUT; + } + + if (result.error != 0) { + free(result.endpoint.txt); + return result.error; + } + + *endpoints = malloc(sizeof(**endpoints)); + + if (!*endpoints) { + free(result.endpoint.txt); + return -ENOMEM; + } + + **endpoints = result.endpoint; + *endpoint_count = 1; + return 0; +} + +static void avahi_stop(void *context_ptr) +{ + struct avahi_context *context = context_ptr; + + if (!context) { + return; + } + + for (size_t i = 0; i < context->browser_count; i++) { + avahi_service_browser_free(context->browsers[i]); + } + + if (context->client) { + avahi_client_free(context->client); + } + + if (context->simple_poll) { + avahi_simple_poll_free(context->simple_poll); + } + + free(context->domain); + free_service_types(context); + free(context); +} + +const struct afpc_discovery_backend_ops afpc_discovery_backend = { + .name = "avahi", + .start = avahi_start, + .iterate = avahi_iterate, + .resolve = avahi_resolve, + .stop = avahi_stop, +}; diff --git a/discovery/backend_dnssd.c b/discovery/backend_dnssd.c new file mode 100644 index 00000000..ead24df0 --- /dev/null +++ b/discovery/backend_dnssd.c @@ -0,0 +1,721 @@ +/* + * Copyright (C) 2026 Daniel Markstedt + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "backend.h" + +struct dnssd_context { + DNSServiceRef browsers[2]; + size_t browser_count; + struct afpc_discovery *discovery; + int callback_error; +}; + +struct dnssd_resolve_result { + int done; + int error; + char target[AFPC_DISCOVERY_TARGET_LEN]; + uint16_t port; + unsigned int interface_index; + unsigned char *txt; + size_t txt_len; +}; + +#ifdef HAVE_DNSSD_GETADDRINFO +struct dnssd_address_result { + const struct dnssd_resolve_result *resolved; + struct afpc_discovery_endpoint *endpoints; + size_t count; + size_t capacity; + int done; + int error; +}; + +static long long now_ms(void) +{ + struct timeval now; + + if (gettimeofday(&now, NULL) != 0) { + return 0; + } + + return (long long)now.tv_sec * 1000LL + now.tv_usec / 1000; +} +#endif + +static int copy_string(char *destination, size_t destination_size, + const char *source) +{ + size_t length; + + if (!destination || destination_size == 0 || !source) { + return -EINVAL; + } + + length = strlen(source); + + if (length >= destination_size) { + return -ENAMETOOLONG; + } + + memcpy(destination, source, length + 1); + return 0; +} + +static void browse_reply(DNSServiceRef service_ref, DNSServiceFlags flags, + uint32_t interface_index, + DNSServiceErrorType error_code, + const char *service_name, const char *regtype, + const char *reply_domain, void *context_ptr) +{ + struct dnssd_context *context = context_ptr; + struct afpc_discovery_backend_event event; + int ret; + (void)service_ref; + memset(&event, 0, sizeof(event)); + + if (error_code != kDNSServiceErr_NoError) { + event.type = AFPC_DISCOVERY_EVENT_ERROR; + event.error = EIO; + event.message = "DNS-SD browse operation failed"; + ret = afpc_discovery_backend_emit(context->discovery, &event); + + if (ret != 0) { + context->callback_error = ret; + } + + return; + } + + event.type = (flags & kDNSServiceFlagsAdd) + ? AFPC_DISCOVERY_EVENT_ADD : AFPC_DISCOVERY_EVENT_REMOVE; + event.source = AFPC_DISCOVERY_SOURCE_UNSPEC; + event.service.interface_index = interface_index; + + if (copy_string(event.service.instance, + sizeof(event.service.instance), service_name) != 0 + || copy_string(event.service.type, sizeof(event.service.type), + regtype) != 0 + || copy_string(event.service.domain, + sizeof(event.service.domain), reply_domain) != 0) { + context->callback_error = -ENAMETOOLONG; + return; + } + + ret = afpc_discovery_backend_emit(context->discovery, &event); + + if (ret != 0) { + context->callback_error = ret; + } +} + +static void resolve_reply(DNSServiceRef service_ref, DNSServiceFlags flags, + uint32_t interface_index, + DNSServiceErrorType error_code, + const char *full_name, const char *host_target, + uint16_t port, uint16_t txt_len, + const unsigned char *txt_record, + void *context_ptr) +{ + struct dnssd_resolve_result *result = context_ptr; + (void)service_ref; + (void)flags; + (void)full_name; + result->done = 1; + + if (error_code != kDNSServiceErr_NoError) { + result->error = -EIO; + return; + } + + result->error = copy_string(result->target, sizeof(result->target), + host_target); + + if (result->error != 0) { + return; + } + + result->interface_index = interface_index; + result->port = ntohs(port); + + if (txt_len != 0) { + result->txt = malloc(txt_len); + + if (!result->txt) { + result->error = -ENOMEM; + return; + } + + memcpy(result->txt, txt_record, txt_len); + result->txt_len = txt_len; + } +} + +static int dnssd_start(void **context_ptr, + struct afpc_discovery *discovery, + const struct afpc_discovery_options *options) +{ + struct dnssd_context *context; + const char *service_types[2]; + size_t service_type_count; + DNSServiceErrorType error; + + if (!context_ptr || !discovery || !options) { + return -EINVAL; + } + + context = calloc(1, sizeof(*context)); + + if (!context) { + return -ENOMEM; + } + + context->discovery = discovery; + service_types[0] = options->service_type + ? options->service_type + : AFPC_DISCOVERY_AFP_SERVICE_TYPE; + service_type_count = 1; + + if (!options->service_type) { + service_types[service_type_count++] = + AFPC_DISCOVERY_DEVICE_INFO_SERVICE_TYPE; + } + + for (size_t i = 0; i < service_type_count; i++) { + error = DNSServiceBrowse(&context->browsers[i], 0, + options->interface_index, + service_types[i], options->domain, + browse_reply, context); + + if (error != kDNSServiceErr_NoError) { + for (size_t j = 0; j < i; j++) { + DNSServiceRefDeallocate(context->browsers[j]); + } + + free(context); + return -EIO; + } + + context->browser_count++; + } + + *context_ptr = context; + return 0; +} + +static int process_ref(DNSServiceRef service_ref, int timeout_ms) +{ + struct pollfd poll_fd; + DNSServiceErrorType error; + int ret; + poll_fd.fd = DNSServiceRefSockFD(service_ref); + poll_fd.events = POLLIN; + poll_fd.revents = 0; + + if (poll_fd.fd < 0) { + return -EIO; + } + + do { + ret = poll(&poll_fd, 1, timeout_ms); + } while (ret < 0 && errno == EINTR); + + if (ret < 0) { + return -errno; + } + + if (ret == 0) { + return 0; + } + + if (poll_fd.revents & (POLLERR | POLLHUP | POLLNVAL)) { + return -EIO; + } + + if (!(poll_fd.revents & POLLIN)) { + return 0; + } + + error = DNSServiceProcessResult(service_ref); + return error == kDNSServiceErr_NoError ? 1 : -EIO; +} + +static int dnssd_iterate(void *context_ptr, + struct afpc_discovery *discovery, int timeout_ms) +{ + struct dnssd_context *context = context_ptr; + struct pollfd poll_fds[2]; + int processed = 0; + int ret; + (void)discovery; + + if (!context) { + return -EINVAL; + } + + context->callback_error = 0; + + for (size_t i = 0; i < context->browser_count; i++) { + poll_fds[i].fd = DNSServiceRefSockFD(context->browsers[i]); + poll_fds[i].events = POLLIN; + poll_fds[i].revents = 0; + + if (poll_fds[i].fd < 0) { + return -EIO; + } + } + + do { + ret = poll(poll_fds, (nfds_t)context->browser_count, timeout_ms); + } while (ret < 0 && errno == EINTR); + + if (ret < 0) { + return -errno; + } + + if (ret == 0) { + return 0; + } + + for (size_t i = 0; i < context->browser_count; i++) { + DNSServiceErrorType error; + + if (poll_fds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) { + return -EIO; + } + + if (!(poll_fds[i].revents & POLLIN)) { + continue; + } + + error = DNSServiceProcessResult(context->browsers[i]); + + if (error != kDNSServiceErr_NoError) { + return -EIO; + } + + processed = 1; + } + + if (context->callback_error != 0) { + return context->callback_error; + } + + return processed; +} + +static int duplicate_txt(struct afpc_discovery_endpoint *endpoint, + const struct dnssd_resolve_result *result) +{ + if (result->txt_len == 0) { + return 0; + } + + endpoint->txt = malloc(result->txt_len); + + if (!endpoint->txt) { + return -ENOMEM; + } + + memcpy(endpoint->txt, result->txt, result->txt_len); + endpoint->txt_len = result->txt_len; + return 0; +} + +#ifdef HAVE_DNSSD_GETADDRINFO +static int same_address(const struct afpc_discovery_endpoint *endpoint, + const struct sockaddr *address, socklen_t address_len, + unsigned int interface_index) +{ + if (endpoint->interface_index != interface_index + || endpoint->address_len != address_len + || endpoint->address.ss_family != address->sa_family) { + return 0; + } + + if (address->sa_family == AF_INET) { + const struct sockaddr_in *left = + (const struct sockaddr_in *)&endpoint->address; + const struct sockaddr_in *right = + (const struct sockaddr_in *)address; + return memcmp(&left->sin_addr, &right->sin_addr, + sizeof(left->sin_addr)) == 0; + } + + if (address->sa_family == AF_INET6) { + const struct sockaddr_in6 *left = + (const struct sockaddr_in6 *)&endpoint->address; + const struct sockaddr_in6 *right = + (const struct sockaddr_in6 *)address; + return memcmp(&left->sin6_addr, &right->sin6_addr, + sizeof(left->sin6_addr)) == 0; + } + + return 0; +} + +static int append_address(struct dnssd_address_result *result, + const struct sockaddr *address, + unsigned int interface_index) +{ + struct afpc_discovery_endpoint *endpoints; + struct afpc_discovery_endpoint *endpoint; + socklen_t address_len; + size_t capacity; + int ret; + + if (address->sa_family == AF_INET) { + address_len = sizeof(struct sockaddr_in); + } else if (address->sa_family == AF_INET6) { + address_len = sizeof(struct sockaddr_in6); + } else { + return 0; + } + + for (size_t i = 0; i < result->count; i++) { + if (same_address(&result->endpoints[i], address, address_len, + interface_index)) { + return 0; + } + } + + if (result->count == result->capacity) { + capacity = result->capacity == 0 ? 4 : result->capacity * 2; + endpoints = realloc(result->endpoints, + capacity * sizeof(*endpoints)); + + if (!endpoints) { + return -ENOMEM; + } + + memset(endpoints + result->capacity, 0, + (capacity - result->capacity) * sizeof(*endpoints)); + result->endpoints = endpoints; + result->capacity = capacity; + } + + endpoint = &result->endpoints[result->count]; + memcpy(&endpoint->address, address, address_len); + endpoint->address_len = address_len; + endpoint->interface_index = interface_index; + endpoint->port = result->resolved->port; + + if (address->sa_family == AF_INET) { + struct sockaddr_in *address4 = + (struct sockaddr_in *)&endpoint->address; + address4->sin_port = htons(endpoint->port); + } else { + struct sockaddr_in6 *address6 = + (struct sockaddr_in6 *)&endpoint->address; + address6->sin6_port = htons(endpoint->port); + + if (IN6_IS_ADDR_LINKLOCAL(&address6->sin6_addr) + && address6->sin6_scope_id == 0) { + address6->sin6_scope_id = interface_index; + } + } + + ret = copy_string(endpoint->target, sizeof(endpoint->target), + result->resolved->target); + + if (ret != 0) { + return ret; + } + + ret = duplicate_txt(endpoint, result->resolved); + + if (ret != 0) { + return ret; + } + + result->count++; + return 0; +} + +static void address_reply(DNSServiceRef service_ref, DNSServiceFlags flags, + uint32_t interface_index, + DNSServiceErrorType error_code, + const char *hostname, + const struct sockaddr *address, uint32_t ttl, + void *context_ptr) +{ + struct dnssd_address_result *result = context_ptr; + (void)service_ref; + (void)hostname; + (void)ttl; + + if (error_code != kDNSServiceErr_NoError) { + result->error = -EIO; + result->done = 1; + return; + } + + if (flags & kDNSServiceFlagsAdd) { + result->error = append_address(result, address, interface_index); + + if (result->error != 0) { + result->done = 1; + return; + } + } + + if (!(flags & kDNSServiceFlagsMoreComing)) { + result->done = 1; + } +} + +static int build_scoped_endpoints( + const struct dnssd_resolve_result *resolved, + struct afpc_discovery_endpoint **endpoints, size_t *endpoint_count, + int timeout_ms) +{ + struct dnssd_address_result result; + DNSServiceRef address_query = NULL; + DNSServiceErrorType error; + long long deadline; + int remaining; + int ret; + memset(&result, 0, sizeof(result)); + result.resolved = resolved; + error = DNSServiceGetAddrInfo( + &address_query, 0, resolved->interface_index, + kDNSServiceProtocol_IPv4 | kDNSServiceProtocol_IPv6, + resolved->target, address_reply, &result); + + if (error != kDNSServiceErr_NoError) { + return -EIO; + } + + deadline = timeout_ms < 0 ? 0 : now_ms() + timeout_ms; + + do { + remaining = timeout_ms < 0 ? -1 : (int)(deadline - now_ms()); + + if (timeout_ms >= 0 && remaining < 0) { + remaining = 0; + } + + ret = process_ref(address_query, remaining); + } while (ret > 0 && !result.done); + + DNSServiceRefDeallocate(address_query); + + if (ret < 0) { + afpc_discovery_free_endpoints(&result.endpoints, result.count); + return ret; + } + + if (!result.done || result.count == 0) { + afpc_discovery_free_endpoints(&result.endpoints, result.count); + return -ETIMEDOUT; + } + + if (result.error != 0) { + afpc_discovery_free_endpoints(&result.endpoints, result.count); + return result.error; + } + + *endpoints = result.endpoints; + *endpoint_count = result.count; + return 0; +} +#endif + +static int build_endpoints(const struct dnssd_resolve_result *result, + struct afpc_discovery_endpoint **endpoints_ptr, + size_t *endpoint_count) +{ + struct addrinfo hints; + struct addrinfo *addresses = NULL; + struct addrinfo *address; + struct afpc_discovery_endpoint *endpoints; + char port[6]; + size_t count = 0; + size_t index = 0; + int ret; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + snprintf(port, sizeof(port), "%u", result->port); + ret = getaddrinfo(result->target, port, &hints, &addresses); + + if (ret == 0) { + for (address = addresses; address; address = address->ai_next) { + if (address->ai_addrlen <= sizeof(struct sockaddr_storage)) { + count++; + } + } + } + + if (count == 0) { + count = 1; + } + + endpoints = calloc(count, sizeof(*endpoints)); + + if (!endpoints) { + if (addresses) { + freeaddrinfo(addresses); + } + + return -ENOMEM; + } + + if (addresses) { + for (address = addresses; address; address = address->ai_next) { + struct sockaddr_in6 *address6; + + if (address->ai_addrlen > sizeof(struct sockaddr_storage)) { + continue; + } + + memcpy(&endpoints[index].address, address->ai_addr, + address->ai_addrlen); + endpoints[index].address_len = address->ai_addrlen; + address6 = (struct sockaddr_in6 *)&endpoints[index].address; + + if (address6->sin6_family == AF_INET6 + && IN6_IS_ADDR_LINKLOCAL(&address6->sin6_addr) + && address6->sin6_scope_id == 0) { + address6->sin6_scope_id = result->interface_index; + } + + index++; + } + } + + if (index == 0) { + index = 1; + } + + for (size_t i = 0; i < index; i++) { + ret = copy_string(endpoints[i].target, + sizeof(endpoints[i].target), result->target); + + if (ret != 0) { + afpc_discovery_free_endpoints(&endpoints, index); + + if (addresses) { + freeaddrinfo(addresses); + } + + return ret; + } + + endpoints[i].port = result->port; + endpoints[i].interface_index = result->interface_index; + ret = duplicate_txt(&endpoints[i], result); + + if (ret != 0) { + afpc_discovery_free_endpoints(&endpoints, index); + + if (addresses) { + freeaddrinfo(addresses); + } + + return ret; + } + } + + if (addresses) { + freeaddrinfo(addresses); + } + + *endpoints_ptr = endpoints; + *endpoint_count = index; + return 0; +} + +static int dnssd_resolve(void *context_ptr, + struct afpc_discovery *discovery, + const struct afpc_discovery_service *service, + struct afpc_discovery_endpoint **endpoints, + size_t *endpoint_count, int timeout_ms) +{ + struct dnssd_resolve_result result; + DNSServiceRef resolver = NULL; + DNSServiceErrorType error; + int ret; + (void)context_ptr; + (void)discovery; + memset(&result, 0, sizeof(result)); + error = DNSServiceResolve(&resolver, 0, service->interface_index, + service->instance, service->type, + service->domain, resolve_reply, &result); + + if (error != kDNSServiceErr_NoError) { + return -EIO; + } + + ret = process_ref(resolver, timeout_ms); + DNSServiceRefDeallocate(resolver); + + if (ret < 0) { + free(result.txt); + return ret; + } + + if (!result.done) { + free(result.txt); + return -ETIMEDOUT; + } + + if (result.error != 0) { + free(result.txt); + return result.error; + } + +#ifdef HAVE_DNSSD_GETADDRINFO + ret = build_scoped_endpoints(&result, endpoints, endpoint_count, + timeout_ms); + + if (ret == 0) { + free(result.txt); + return 0; + } + +#endif + ret = build_endpoints(&result, endpoints, endpoint_count); + free(result.txt); + return ret; +} + +static void dnssd_stop(void *context_ptr) +{ + struct dnssd_context *context = context_ptr; + + if (!context) { + return; + } + + for (size_t i = 0; i < context->browser_count; i++) { + DNSServiceRefDeallocate(context->browsers[i]); + } + + free(context); +} + +const struct afpc_discovery_backend_ops afpc_discovery_backend = { + .name = "dnssd", + .start = dnssd_start, + .iterate = dnssd_iterate, + .resolve = dnssd_resolve, + .stop = dnssd_stop, +}; diff --git a/discovery/backend_stub.c b/discovery/backend_stub.c new file mode 100644 index 00000000..f9d8dc17 --- /dev/null +++ b/discovery/backend_stub.c @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2026 Daniel Markstedt + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + */ + +#include + +#include "backend.h" + +static int stub_start(void **context, struct afpc_discovery *discovery, + const struct afpc_discovery_options *options) +{ + (void)context; + (void)discovery; + (void)options; + return -ENOTSUP; +} + +static int stub_iterate(void *context, struct afpc_discovery *discovery, + int timeout_ms) +{ + (void)context; + (void)discovery; + (void)timeout_ms; + return -ENOTSUP; +} + +static int stub_resolve(void *context, struct afpc_discovery *discovery, + const struct afpc_discovery_service *service, + struct afpc_discovery_endpoint **endpoints, + size_t *endpoint_count, int timeout_ms) +{ + (void)context; + (void)discovery; + (void)service; + (void)endpoints; + (void)endpoint_count; + (void)timeout_ms; + return -ENOTSUP; +} + +static void stub_stop(void *context) +{ + (void)context; +} + +const struct afpc_discovery_backend_ops afpc_discovery_backend = { + .name = "stub", + .start = stub_start, + .iterate = stub_iterate, + .resolve = stub_resolve, + .stop = stub_stop, +}; diff --git a/discovery/client/discover.c b/discovery/client/discover.c new file mode 100644 index 00000000..a1ef723e --- /dev/null +++ b/discovery/client/discover.c @@ -0,0 +1,844 @@ +/* + * Copyright (C) 2026 Daniel Markstedt + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "discovery/discovery.h" + +#include "discovery/client/discover.h" + +#define DISCOVERY_DEFAULT_TIMEOUT_MS 1500 +#define DISCOVERY_RESOLVE_TIMEOUT_MS 1000 +#define DISCOVERY_DEVICE_TYPE_LEN 256 + +struct resolved_service { + struct afpc_discovery_service service; + struct afpc_discovery_endpoint *endpoints; + size_t endpoint_count; + int resolve_error; + char device_type[DISCOVERY_DEVICE_TYPE_LEN]; +}; + +static void discover_usage(FILE *stream) +{ + fputs("Usage: afp_client discover [--verbose | --json] " + "[--timeout milliseconds]\n", + stream); +} + +static int service_compare(const void *left_ptr, const void *right_ptr) +{ + const struct afpc_discovery_service *left = left_ptr; + const struct afpc_discovery_service *right = right_ptr; + int result = strcmp(left->instance, right->instance); + + if (result == 0) { + result = strcmp(left->domain, right->domain); + } + + if (result == 0) { + if (left->interface_index < right->interface_index) { + result = -1; + } else if (left->interface_index > right->interface_index) { + result = 1; + } + } + + return result; +} + +static void print_json_string(FILE *stream, const char *text) +{ + const unsigned char *p = (const unsigned char *)text; + fputc('"', stream); + + while (*p) { + switch (*p) { + case '"': + fputs("\\\"", stream); + break; + + case '\\': + fputs("\\\\", stream); + break; + + case '\b': + fputs("\\b", stream); + break; + + case '\f': + fputs("\\f", stream); + break; + + case '\n': + fputs("\\n", stream); + break; + + case '\r': + fputs("\\r", stream); + break; + + case '\t': + fputs("\\t", stream); + break; + + default: + if (*p < 0x20) { + fprintf(stream, "\\u%04x", *p); + } else { + fputc(*p, stream); + } + + break; + } + + p++; + } + + fputc('"', stream); +} + +static void print_hex(FILE *stream, const unsigned char *data, size_t size) +{ + static const char digits[] = "0123456789abcdef"; + + for (size_t i = 0; i < size; i++) { + fputc(digits[data[i] >> 4], stream); + fputc(digits[data[i] & 0x0f], stream); + } +} + +static const char *interface_text(unsigned int interface_index, + char *buffer, size_t buffer_size) +{ + if (interface_index != 0 + && if_indextoname(interface_index, buffer)) { + return buffer; + } + + snprintf(buffer, buffer_size, "%u", interface_index); + return buffer; +} + +static int service_has_type(const struct afpc_discovery_service *service, + const char *type) +{ + return strcasecmp(service->type, type) == 0; +} + +static int is_afp_service(const struct afpc_discovery_service *service) +{ + return service_has_type(service, AFPC_DISCOVERY_AFP_SERVICE_TYPE); +} + +static int is_device_info_service( + const struct afpc_discovery_service *service) +{ + return service_has_type(service, + AFPC_DISCOVERY_DEVICE_INFO_SERVICE_TYPE); +} + +static int txt_key_equal(const unsigned char *item, size_t item_len, + const char *key) +{ + size_t key_len = strlen(key); + + if (item_len < key_len + 1 || item[key_len] != '=') { + return 0; + } + + for (size_t i = 0; i < key_len; i++) { + if (tolower(item[i]) != tolower((unsigned char)key[i])) { + return 0; + } + } + + return 1; +} + +static void extract_device_type(const unsigned char *txt, size_t txt_len, + char *device_type, size_t device_type_size) +{ + size_t offset = 0; + + if (!txt || !device_type || device_type_size == 0) { + return; + } + + while (offset < txt_len) { + size_t item_len = txt[offset++]; + const unsigned char *item; + size_t value_len; + + if (item_len > txt_len - offset) { + return; + } + + item = txt + offset; + offset += item_len; + + if (!txt_key_equal(item, item_len, "model")) { + continue; + } + + value_len = item_len - (sizeof("model=") - 1); + + if (value_len >= device_type_size) { + value_len = device_type_size - 1; + } + + for (size_t i = 0; i < value_len; i++) { + unsigned char ch = item[sizeof("model=") - 1 + i]; + device_type[i] = (ch < 0x20 || ch == 0x7f) ? '?' : (char)ch; + } + + device_type[value_len] = '\0'; + return; + } +} + +static int corresponding_service_records( + const struct afpc_discovery_service *afp, + const struct afpc_discovery_service *device_info) +{ + return is_afp_service(afp) + && service_has_type(device_info, + AFPC_DISCOVERY_DEVICE_INFO_SERVICE_TYPE) + && strcmp(afp->instance, device_info->instance) == 0 + && strcasecmp(afp->domain, device_info->domain) == 0 + && afp->interface_index == device_info->interface_index; +} + +static int corresponding_device_info( + const struct resolved_service *afp, + const struct resolved_service *device_info) +{ + return corresponding_service_records(&afp->service, + &device_info->service); +} + +static int has_corresponding_afp( + const struct afpc_discovery_service *services, size_t count, + const struct afpc_discovery_service *device_info) +{ + for (size_t i = 0; i < count; i++) { + if (corresponding_service_records(&services[i], device_info)) { + return 1; + } + } + + return 0; +} + +static int same_service_identity(const struct resolved_service *left, + const struct resolved_service *right) +{ + return strcmp(left->service.instance, right->service.instance) == 0 + && strcasecmp(left->service.type, right->service.type) == 0 + && strcasecmp(left->service.domain, right->service.domain) == 0; +} + +static int same_logical_service(const struct resolved_service *left, + const struct resolved_service *right) +{ + if (!same_service_identity(left, right) + || left->resolve_error != 0 || right->resolve_error != 0) { + return 0; + } + + return left->endpoint_count != 0 && right->endpoint_count != 0 + && left->endpoints[0].port == right->endpoints[0].port + && strcasecmp(left->endpoints[0].target, + right->endpoints[0].target) == 0; +} + +static void normalize_device_types(struct resolved_service *services, + size_t count) +{ + for (size_t i = 0; i < count; i++) { + if (!is_device_info_service(&services[i].service) + || services[i].resolve_error != 0 + || services[i].endpoint_count == 0) { + continue; + } + + extract_device_type(services[i].endpoints[0].txt, + services[i].endpoints[0].txt_len, + services[i].device_type, + sizeof(services[i].device_type)); + } + + for (size_t i = 0; i < count; i++) { + if (!is_afp_service(&services[i].service)) { + continue; + } + + for (size_t j = 0; j < count; j++) { + if (!corresponding_device_info(&services[i], &services[j]) + || services[j].device_type[0] == '\0') { + continue; + } + + memcpy(services[i].device_type, services[j].device_type, + sizeof(services[i].device_type)); + break; + } + } + + for (size_t i = 0; i < count; i++) { + if (!is_afp_service(&services[i].service) + || services[i].device_type[0] != '\0') { + continue; + } + + for (size_t j = 0; j < count; j++) { + if (services[j].device_type[0] != '\0' + && same_logical_service(&services[i], &services[j])) { + memcpy(services[i].device_type, services[j].device_type, + sizeof(services[i].device_type)); + break; + } + } + } +} + +static size_t afp_service_count(const struct resolved_service *services, + size_t count) +{ + size_t result = 0; + + for (size_t i = 0; i < count; i++) { + result += is_afp_service(&services[i].service) ? 1 : 0; + } + + return result; +} + +static size_t verbose_service_count(const struct resolved_service *services, + size_t count) +{ + size_t result = 0; + + for (size_t i = 0; i < count; i++) { + result += is_afp_service(&services[i].service) + || is_device_info_service(&services[i].service) ? 1 : 0; + } + + return result; +} + +static void print_human_target(const char *target) +{ + size_t length = strlen(target); + + if (length > 0 && target[length - 1] == '.') { + length--; + } + + printf("%-36.*s ", (int)length, target); +} + +static void print_human(const struct resolved_service *services, size_t count, + int verbose) +{ + size_t i; + + if ((!verbose && afp_service_count(services, count) == 0) + || (verbose && verbose_service_count(services, count) == 0)) { + puts("No AFP services found."); + return; + } + + if (!verbose) { + printf("%-30s %-36s %5s %-24s\n", "NAME", "TARGET", "PORT", + "MODEL"); + + for (i = 0; i < count; i++) { + const struct afpc_discovery_endpoint *endpoint = + services[i].endpoint_count ? &services[i].endpoints[0] : NULL; + int duplicate = 0; + + if (!is_afp_service(&services[i].service)) { + continue; + } + + if (services[i].resolve_error != 0) { + for (size_t j = 0; j < count; j++) { + if (j != i && services[j].resolve_error == 0 + && same_service_identity(&services[i], + &services[j])) { + duplicate = 1; + break; + } + } + + for (size_t j = 0; j < i; j++) { + if (services[j].resolve_error != 0 + && same_service_identity(&services[i], + &services[j])) { + duplicate = 1; + break; + } + } + } else { + for (size_t j = 0; j < i; j++) { + if (same_logical_service(&services[i], &services[j])) { + duplicate = 1; + break; + } + } + } + + if (duplicate) { + continue; + } + + printf("%-30s ", services[i].service.instance); + print_human_target(endpoint ? endpoint->target : "-"); + + if (endpoint) { + printf("%5u", endpoint->port); + } else { + printf("%5s", "-"); + } + + printf(" %-24s", services[i].device_type[0] + ? services[i].device_type : "-"); + putchar('\n'); + } + + return; + } + + size_t printed = 0; + + for (i = 0; i < count; i++) { + char interface_name[IF_NAMESIZE]; + + if (!is_afp_service(&services[i].service) + && !is_device_info_service(&services[i].service)) { + continue; + } + + if (printed != 0) { + putchar('\n'); + } + + printed++; + printf("Name: %s\n", services[i].service.instance); + printf("Type: %s\n", services[i].service.type); + printf("Device: %s\n", services[i].device_type[0] + ? services[i].device_type : "-"); + printf("Domain: %s\n", services[i].service.domain); + printf("Interface: %s\n", + interface_text(services[i].service.interface_index, + interface_name, sizeof(interface_name))); + + if (services[i].resolve_error != 0) { + printf("Resolution: %s\n", strerror(-services[i].resolve_error)); + continue; + } + + printf("Target: %s\n", services[i].endpoints[0].target); + printf("Port: %u\n", services[i].endpoints[0].port); + fputs("Addresses: ", stdout); + size_t addresses_printed = 0; + + for (size_t j = 0; j < services[i].endpoint_count; j++) { + char address[INET6_ADDRSTRLEN + IF_NAMESIZE + 2]; + + if (afpc_discovery_endpoint_host(&services[i].endpoints[j], + address, + sizeof(address)) != 0) { + continue; + } + + if (addresses_printed != 0) { + fputs(", ", stdout); + } + + fputs(address, stdout); + addresses_printed++; + } + + putchar('\n'); + + if (services[i].endpoints[0].txt_len != 0) { + fputs("TXT (hex): ", stdout); + print_hex(stdout, services[i].endpoints[0].txt, + services[i].endpoints[0].txt_len); + putchar('\n'); + } + } +} + +static void print_json(const struct resolved_service *services, size_t count) +{ + size_t services_printed = 0; + fputs("{\"backend\":", stdout); + print_json_string(stdout, afpc_discovery_backend_name()); + fputs(",\"services\":[", stdout); + + for (size_t i = 0; i < count; i++) { + if (!is_afp_service(&services[i].service)) { + continue; + } + + if (services_printed != 0) { + putchar(','); + } + + services_printed++; + fputs("{\"name\":", stdout); + print_json_string(stdout, services[i].service.instance); + fputs(",\"type\":", stdout); + print_json_string(stdout, services[i].service.type); + fputs(",\"device_type\":", stdout); + + if (services[i].device_type[0] != '\0') { + print_json_string(stdout, services[i].device_type); + } else { + fputs("null", stdout); + } + + fputs(",\"domain\":", stdout); + print_json_string(stdout, services[i].service.domain); + printf(",\"interface_index\":%u", + services[i].service.interface_index); + + if (services[i].resolve_error != 0) { + printf(",\"resolve_error\":%d", -services[i].resolve_error); + } else { + fputs(",\"target\":", stdout); + print_json_string(stdout, services[i].endpoints[0].target); + printf(",\"port\":%u,\"addresses\":[", + services[i].endpoints[0].port); + size_t addresses_printed = 0; + + for (size_t j = 0; j < services[i].endpoint_count; j++) { + char address[INET6_ADDRSTRLEN + IF_NAMESIZE + 2]; + + if (afpc_discovery_endpoint_host(&services[i].endpoints[j], + address, + sizeof(address)) != 0) { + continue; + } + + if (addresses_printed != 0) { + putchar(','); + } + + print_json_string(stdout, address); + addresses_printed++; + } + + fputs("],\"txt_hex\":\"", stdout); + print_hex(stdout, services[i].endpoints[0].txt, + services[i].endpoints[0].txt_len); + putchar('"'); + } + + putchar('}'); + } + + fputs("]}\n", stdout); +} + +static void free_resolved_services(struct resolved_service *services, + size_t count) +{ + if (!services) { + return; + } + + for (size_t i = 0; i < count; i++) { + afpc_discovery_free_endpoints(&services[i].endpoints, + services[i].endpoint_count); + } + + free(services); +} + +static int parse_timeout(const char *text, int *timeout_ms) +{ + char *end; + long value; + errno = 0; + value = strtol(text, &end, 10); + + if (errno != 0 || *text == '\0' || *end != '\0' + || value < 0 || value > 60000) { + return -1; + } + + *timeout_ms = (int)value; + return 0; +} + +static int endpoint_score(const struct afpc_discovery_endpoint *endpoint) +{ + if (endpoint->address_len == 0) { + return endpoint->target[0] == '\0' ? -1 : 1; + } + + if (endpoint->address.ss_family == AF_INET) { + const struct sockaddr_in *address = + (const struct sockaddr_in *)&endpoint->address; + uint32_t value = ntohl(address->sin_addr.s_addr); + return (value & 0xff000000U) == 0x7f000000U ? 30 : 130; + } + + if (endpoint->address.ss_family == AF_INET6) { + const struct sockaddr_in6 *address = + (const struct sockaddr_in6 *)&endpoint->address; + return IN6_IS_ADDR_LOOPBACK(&address->sin6_addr) ? 20 : 120; + } + + return -1; +} + +int afpc_discover_resolve_service(const char *name, char *host, + size_t host_size, uint16_t *port, + int timeout_ms) +{ + struct afpc_discovery *discovery = NULL; + struct afpc_discovery_service *services = NULL; + size_t service_count = 0; + const char *domain = NULL; + char resolved_target[AFPC_DISCOVERY_TARGET_LEN] = {0}; + uint16_t resolved_port = 0; + int best_score = -1; + int last_error = -EHOSTUNREACH; + int match_count = 0; + int ret; + + if (!name || name[0] == '\0' || !host || host_size == 0 || !port + || timeout_ms < 0) { + return -EINVAL; + } + + host[0] = '\0'; + *port = 0; + ret = afpc_discovery_open(&discovery, NULL); + + if (ret != 0) { + return ret; + } + + ret = afpc_discovery_snapshot(discovery, &services, &service_count, + timeout_ms); + + if (ret != 0) { + goto done; + } + + for (size_t i = 0; i < service_count; i++) { + if (!is_afp_service(&services[i]) + || strcmp(services[i].instance, name) != 0) { + continue; + } + + match_count++; + + if (!domain) { + domain = services[i].domain; + } else if (strcasecmp(domain, services[i].domain) != 0) { + ret = -EEXIST; + goto done; + } + } + + if (match_count == 0) { + ret = -ENOENT; + goto done; + } + + for (size_t i = 0; i < service_count; i++) { + struct afpc_discovery_endpoint *endpoints = NULL; + size_t endpoint_count = 0; + + if (!is_afp_service(&services[i]) + || strcmp(services[i].instance, name) != 0) { + continue; + } + + ret = afpc_discovery_resolve(discovery, &services[i], &endpoints, + &endpoint_count, + DISCOVERY_RESOLVE_TIMEOUT_MS); + + if (ret != 0) { + last_error = ret; + afpc_discovery_free_endpoints(&endpoints, endpoint_count); + continue; + } + + for (size_t j = 0; j < endpoint_count; j++) { + int score; + char endpoint_host[AFPC_DISCOVERY_TARGET_LEN + IF_NAMESIZE + 2]; + + if (resolved_port == 0) { + snprintf(resolved_target, sizeof(resolved_target), "%s", + endpoints[j].target); + resolved_port = endpoints[j].port; + } else if (resolved_port != endpoints[j].port + || strcasecmp(resolved_target, + endpoints[j].target) != 0) { + afpc_discovery_free_endpoints(&endpoints, endpoint_count); + ret = -EEXIST; + goto done; + } + + score = endpoint_score(&endpoints[j]); + + if (score <= best_score + || afpc_discovery_endpoint_host(&endpoints[j], + endpoint_host, + sizeof(endpoint_host)) != 0) { + continue; + } + + if (strlen(endpoint_host) >= host_size) { + last_error = -ENAMETOOLONG; + continue; + } + + memcpy(host, endpoint_host, strlen(endpoint_host) + 1); + best_score = score; + } + + afpc_discovery_free_endpoints(&endpoints, endpoint_count); + } + + if (best_score < 0 || resolved_port == 0) { + ret = last_error; + goto done; + } + + *port = resolved_port; + ret = 0; +done: + afpc_discovery_free_services(&services); + afpc_discovery_close(&discovery); + return ret; +} + +int afpc_discover_command(int argc, char **argv) +{ + struct afpc_discovery *discovery = NULL; + struct afpc_discovery_service *services = NULL; + struct resolved_service *resolved = NULL; + size_t service_count = 0; + int timeout_ms = DISCOVERY_DEFAULT_TIMEOUT_MS; + int verbose = 0; + int json = 0; + int ret; + + for (int argi = 1; argi < argc; argi++) { + if (strcmp(argv[argi], "--verbose") == 0 + || strcmp(argv[argi], "-v") == 0) { + verbose = 1; + } else if (strcmp(argv[argi], "--json") == 0) { + json = 1; + } else if (strcmp(argv[argi], "--timeout") == 0) { + if (++argi >= argc || parse_timeout(argv[argi], &timeout_ms) != 0) { + fprintf(stderr, "Invalid discovery timeout\n"); + discover_usage(stderr); + return 2; + } + } else if (strcmp(argv[argi], "--help") == 0 + || strcmp(argv[argi], "-h") == 0) { + discover_usage(stdout); + return 0; + } else { + fprintf(stderr, "Unknown discover option: %s\n", argv[argi]); + discover_usage(stderr); + return 2; + } + } + + if (verbose && json) { + fputs("--verbose and --json cannot be combined\n", stderr); + return 2; + } + + ret = afpc_discovery_open(&discovery, NULL); + + if (ret != 0) { + if (ret == -ENOTSUP) { + fputs("Zeroconf discovery support was not built.\n", stderr); + } else { + fprintf(stderr, "Could not start Zeroconf discovery: %s\n", + strerror(-ret)); + } + + return 1; + } + + ret = afpc_discovery_snapshot(discovery, &services, &service_count, + timeout_ms); + + if (ret != 0) { + fprintf(stderr, "Zeroconf discovery failed: %s\n", strerror(-ret)); + afpc_discovery_close(&discovery); + return 1; + } + + if (service_count > 1) { + qsort(services, service_count, sizeof(*services), service_compare); + } + + if (service_count != 0) { + resolved = calloc(service_count, sizeof(*resolved)); + + if (!resolved) { + afpc_discovery_free_services(&services); + afpc_discovery_close(&discovery); + fputs("Out of memory resolving discovered services\n", stderr); + return 1; + } + } + + for (size_t i = 0; i < service_count; i++) { + resolved[i].service = services[i]; + + if (!is_afp_service(&services[i]) + && !(verbose && is_device_info_service(&services[i])) + && !has_corresponding_afp(services, service_count, + &services[i])) { + continue; + } + + resolved[i].resolve_error = afpc_discovery_resolve( + discovery, &services[i], + &resolved[i].endpoints, + &resolved[i].endpoint_count, + DISCOVERY_RESOLVE_TIMEOUT_MS); + } + + normalize_device_types(resolved, service_count); + + if (json) { + print_json(resolved, service_count); + } else { + print_human(resolved, service_count, verbose); + } + + free_resolved_services(resolved, service_count); + afpc_discovery_free_services(&services); + afpc_discovery_close(&discovery); + return 0; +} diff --git a/discovery/client/discover.h b/discovery/client/discover.h new file mode 100644 index 00000000..033d6e39 --- /dev/null +++ b/discovery/client/discover.h @@ -0,0 +1,12 @@ +#ifndef NETATALK_CLIENT_DISCOVERY_CLIENT_DISCOVER_H +#define NETATALK_CLIENT_DISCOVERY_CLIENT_DISCOVER_H + +#include +#include + +int afpc_discover_command(int argc, char **argv); +int afpc_discover_resolve_service(const char *name, char *host, + size_t host_size, uint16_t *port, + int timeout_ms); + +#endif diff --git a/discovery/discovery.c b/discovery/discovery.c new file mode 100644 index 00000000..bdeafe89 --- /dev/null +++ b/discovery/discovery.c @@ -0,0 +1,564 @@ +/* + * Copyright (C) 2026 Daniel Markstedt + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "backend.h" + +struct discovery_entry { + struct afpc_discovery_service service; + unsigned int sources; +}; + +struct discovery_event_node { + struct afpc_discovery_event event; + struct discovery_event_node *next; +}; + +struct afpc_discovery { + void *backend_context; + struct discovery_entry *entries; + size_t entry_count; + size_t entry_capacity; + struct discovery_event_node *events_head; + struct discovery_event_node *events_tail; +}; + +static long long now_ms(void) +{ + struct timeval now; + + if (gettimeofday(&now, NULL) != 0) { + return 0; + } + + return (long long)now.tv_sec * 1000LL + now.tv_usec / 1000; +} + +static int copy_network_text(char *destination, size_t destination_size, + const char *source) +{ + size_t source_len; + + if (!destination || destination_size == 0 || !source) { + return -EINVAL; + } + + source_len = strlen(source); + + if (source_len >= destination_size) { + return -ENAMETOOLONG; + } + + for (size_t i = 0; i < source_len; i++) { + unsigned char ch = (unsigned char)source[i]; + destination[i] = (ch < 0x20 || ch == 0x7f) ? '?' : (char)ch; + } + + destination[source_len] = '\0'; + return 0; +} + +static int copy_service(struct afpc_discovery_service *destination, + const struct afpc_discovery_service *source) +{ + int ret; + memset(destination, 0, sizeof(*destination)); + ret = copy_network_text(destination->instance, + sizeof(destination->instance), source->instance); + + if (ret != 0) { + return ret; + } + + ret = copy_network_text(destination->type, sizeof(destination->type), + source->type); + + if (ret != 0) { + return ret; + } + + size_t type_len = strlen(destination->type); + + if (type_len > 0 && destination->type[type_len - 1] == '.') { + destination->type[type_len - 1] = '\0'; + } + + ret = copy_network_text(destination->domain, + sizeof(destination->domain), source->domain); + + if (ret != 0) { + return ret; + } + + destination->interface_index = source->interface_index; + return 0; +} + +static int service_equal(const struct afpc_discovery_service *left, + const struct afpc_discovery_service *right) +{ + return left->interface_index == right->interface_index + && strcmp(left->instance, right->instance) == 0 + && strcmp(left->type, right->type) == 0 + && strcmp(left->domain, right->domain) == 0; +} + +static int find_entry(const struct afpc_discovery *discovery, + const struct afpc_discovery_service *service) +{ + for (size_t i = 0; i < discovery->entry_count; i++) { + if (service_equal(&discovery->entries[i].service, service)) { + return (int)i; + } + } + + return -1; +} + +static int reserve_entry(struct afpc_discovery *discovery) +{ + struct discovery_entry *entries; + size_t capacity; + + if (discovery->entry_count < discovery->entry_capacity) { + return 0; + } + + capacity = discovery->entry_capacity == 0 + ? 8 : discovery->entry_capacity * 2; + entries = realloc(discovery->entries, capacity * sizeof(*entries)); + + if (!entries) { + return -ENOMEM; + } + + discovery->entries = entries; + discovery->entry_capacity = capacity; + return 0; +} + +static int queue_event(struct afpc_discovery *discovery, + enum afpc_discovery_event_type type, + const struct afpc_discovery_service *service, + int error, const char *message) +{ + struct discovery_event_node *node = calloc(1, sizeof(*node)); + int ret; + + if (!node) { + return -ENOMEM; + } + + node->event.type = type; + node->event.error = error; + + if (service) { + ret = copy_service(&node->event.service, service); + + if (ret != 0) { + free(node); + return ret; + } + } + + if (message) { + ret = copy_network_text(node->event.message, + sizeof(node->event.message), message); + + if (ret != 0) { + free(node); + return ret; + } + } + + if (discovery->events_tail) { + discovery->events_tail->next = node; + } else { + discovery->events_head = node; + } + + discovery->events_tail = node; + return 0; +} + +static int pop_event(struct afpc_discovery *discovery, + struct afpc_discovery_event *event) +{ + struct discovery_event_node *node = discovery->events_head; + + if (!node) { + return 0; + } + + discovery->events_head = node->next; + + if (!discovery->events_head) { + discovery->events_tail = NULL; + } + + *event = node->event; + free(node); + return 1; +} + +int afpc_discovery_backend_emit( + struct afpc_discovery *discovery, + const struct afpc_discovery_backend_event *event) +{ + struct afpc_discovery_service service; + unsigned int source; + int index; + int ret; + + if (!discovery || !event) { + return -EINVAL; + } + + if (event->type == AFPC_DISCOVERY_EVENT_ERROR) { + return queue_event(discovery, event->type, NULL, event->error, + event->message); + } + + ret = copy_service(&service, &event->service); + + if (ret != 0) { + return ret; + } + + source = event->source ? event->source : AFPC_DISCOVERY_SOURCE_UNSPEC; + index = find_entry(discovery, &service); + + if (event->type == AFPC_DISCOVERY_EVENT_ADD) { + if (index >= 0) { + discovery->entries[index].sources |= source; + return 0; + } + + ret = reserve_entry(discovery); + + if (ret != 0) { + return ret; + } + + discovery->entries[discovery->entry_count].service = service; + discovery->entries[discovery->entry_count].sources = source; + discovery->entry_count++; + return queue_event(discovery, event->type, &service, 0, NULL); + } + + if (event->type == AFPC_DISCOVERY_EVENT_UPDATE) { + if (index < 0) { + return -ENOENT; + } + + return queue_event(discovery, event->type, &service, 0, NULL); + } + + if (event->type != AFPC_DISCOVERY_EVENT_REMOVE || index < 0) { + return 0; + } + + discovery->entries[index].sources &= ~source; + + if (discovery->entries[index].sources != 0) { + return 0; + } + + ret = queue_event(discovery, event->type, + &discovery->entries[index].service, 0, NULL); + + if (ret != 0) { + return ret; + } + + if ((size_t)index + 1 < discovery->entry_count) { + memmove(&discovery->entries[index], &discovery->entries[index + 1], + (discovery->entry_count - (size_t)index - 1) + * sizeof(*discovery->entries)); + } + + discovery->entry_count--; + return 0; +} + +int afpc_discovery_open(struct afpc_discovery **discovery, + const struct afpc_discovery_options *options) +{ + struct afpc_discovery_options defaults = { + .service_type = NULL, + .domain = NULL, + .interface_index = 0, + }; + struct afpc_discovery *new_discovery; + int ret; + + if (!discovery) { + return -EINVAL; + } + + *discovery = NULL; + new_discovery = calloc(1, sizeof(*new_discovery)); + + if (!new_discovery) { + return -ENOMEM; + } + + if (!options) { + options = &defaults; + } + + ret = afpc_discovery_backend.start(&new_discovery->backend_context, + new_discovery, options); + + if (ret != 0) { + free(new_discovery); + return ret; + } + + *discovery = new_discovery; + return 0; +} + +int afpc_discovery_next(struct afpc_discovery *discovery, + struct afpc_discovery_event *event, + int timeout_ms) +{ + long long deadline; + int remaining; + int ret; + + if (!discovery || !event || timeout_ms < -1) { + return -EINVAL; + } + + ret = pop_event(discovery, event); + + if (ret != 0) { + return ret; + } + + deadline = timeout_ms < 0 ? 0 : now_ms() + timeout_ms; + + do { + remaining = timeout_ms < 0 ? -1 : (int)(deadline - now_ms()); + + if (timeout_ms >= 0 && remaining < 0) { + remaining = 0; + } + + ret = afpc_discovery_backend.iterate(discovery->backend_context, + discovery, remaining); + + if (ret < 0) { + return ret; + } + + ret = pop_event(discovery, event); + + if (ret != 0) { + return ret; + } + + if (timeout_ms == 0) { + return 0; + } + } while (timeout_ms < 0 || now_ms() < deadline); + + return 0; +} + +int afpc_discovery_resolve(struct afpc_discovery *discovery, + const struct afpc_discovery_service *service, + struct afpc_discovery_endpoint **endpoints, + size_t *endpoint_count, int timeout_ms) +{ + if (!discovery || !service || !endpoints || !endpoint_count + || timeout_ms < -1) { + return -EINVAL; + } + + *endpoints = NULL; + *endpoint_count = 0; + return afpc_discovery_backend.resolve(discovery->backend_context, + discovery, service, endpoints, + endpoint_count, timeout_ms); +} + +int afpc_discovery_snapshot(struct afpc_discovery *discovery, + struct afpc_discovery_service **services, + size_t *service_count, int timeout_ms) +{ + struct afpc_discovery_event event; + struct afpc_discovery_service *result = NULL; + long long deadline; + int remaining; + int ret; + + if (!discovery || !services || !service_count || timeout_ms < 0) { + return -EINVAL; + } + + *services = NULL; + *service_count = 0; + deadline = now_ms() + timeout_ms; + + do { + remaining = (int)(deadline - now_ms()); + + if (remaining < 0) { + remaining = 0; + } + + ret = afpc_discovery_next(discovery, &event, remaining); + + if (ret < 0) { + return ret; + } + + if (ret > 0 && event.type == AFPC_DISCOVERY_EVENT_ERROR) { + return event.error > 0 ? -event.error : event.error; + } + } while (ret > 0 && now_ms() < deadline); + + if (discovery->entry_count != 0) { + result = malloc(discovery->entry_count * sizeof(*result)); + + if (!result) { + return -ENOMEM; + } + + for (size_t i = 0; i < discovery->entry_count; i++) { + result[i] = discovery->entries[i].service; + } + } + + *services = result; + *service_count = discovery->entry_count; + return 0; +} + +void afpc_discovery_free_services(struct afpc_discovery_service **services) +{ + if (!services) { + return; + } + + free(*services); + *services = NULL; +} + +void afpc_discovery_free_endpoints( + struct afpc_discovery_endpoint **endpoints, size_t endpoint_count) +{ + if (!endpoints || !*endpoints) { + return; + } + + for (size_t i = 0; i < endpoint_count; i++) { + free((*endpoints)[i].txt); + } + + free(*endpoints); + *endpoints = NULL; +} + +int afpc_discovery_endpoint_host( + const struct afpc_discovery_endpoint *endpoint, + char *host, size_t host_size) +{ + const void *source; + int family; + + if (!endpoint || !host || host_size == 0) { + return -EINVAL; + } + + if (endpoint->address_len == 0) { + return copy_network_text(host, host_size, endpoint->target); + } + + family = endpoint->address.ss_family; + + if (family == AF_INET) { + const struct sockaddr_in *address4 = + (const struct sockaddr_in *)&endpoint->address; + source = &address4->sin_addr; + } else if (family == AF_INET6) { + const struct sockaddr_in6 *address6 = + (const struct sockaddr_in6 *)&endpoint->address; + source = &address6->sin6_addr; + } else { + return -EAFNOSUPPORT; + } + + if (!inet_ntop(family, source, host, (socklen_t)host_size)) { + return -errno; + } + + if (family == AF_INET6) { + const struct sockaddr_in6 *address6 = + (const struct sockaddr_in6 *)&endpoint->address; + + if (address6->sin6_scope_id != 0) { + char interface_name[IF_NAMESIZE]; + char scoped[INET6_ADDRSTRLEN + IF_NAMESIZE + 2]; + const char *scope; + char numeric_scope[16]; + scope = if_indextoname(address6->sin6_scope_id, interface_name); + + if (!scope) { + snprintf(numeric_scope, sizeof(numeric_scope), "%u", + address6->sin6_scope_id); + scope = numeric_scope; + } + + if (snprintf(scoped, sizeof(scoped), "%s%%%s", host, scope) + >= (int)sizeof(scoped) + || strlen(scoped) >= host_size) { + return -ENAMETOOLONG; + } + + memcpy(host, scoped, strlen(scoped) + 1); + } + } + + return 0; +} + +void afpc_discovery_close(struct afpc_discovery **discovery) +{ + if (!discovery || !*discovery) { + return; + } + + afpc_discovery_backend.stop((*discovery)->backend_context); + + while ((*discovery)->events_head) { + struct discovery_event_node *event = (*discovery)->events_head; + (*discovery)->events_head = event->next; + free(event); + } + + free((*discovery)->entries); + free(*discovery); + *discovery = NULL; +} + +const char *afpc_discovery_backend_name(void) +{ + return afpc_discovery_backend.name; +} diff --git a/discovery/discovery.h b/discovery/discovery.h new file mode 100644 index 00000000..cb2bc933 --- /dev/null +++ b/discovery/discovery.h @@ -0,0 +1,87 @@ +#ifndef NETATALK_CLIENT_DISCOVERY_H +#define NETATALK_CLIENT_DISCOVERY_H + +#include +#include +#include + +#include "service_types.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define AFPC_DISCOVERY_INSTANCE_LEN 64 +#define AFPC_DISCOVERY_TYPE_LEN 64 +#define AFPC_DISCOVERY_DOMAIN_LEN 256 +#define AFPC_DISCOVERY_TARGET_LEN 256 +#define AFPC_DISCOVERY_ERROR_LEN 256 + +struct afpc_discovery; + +struct afpc_discovery_options { + /* NULL browses AFP and its companion device-info advertisements. */ + const char *service_type; + const char *domain; + unsigned int interface_index; +}; + +struct afpc_discovery_service { + char instance[AFPC_DISCOVERY_INSTANCE_LEN]; + char type[AFPC_DISCOVERY_TYPE_LEN]; + char domain[AFPC_DISCOVERY_DOMAIN_LEN]; + unsigned int interface_index; +}; + +struct afpc_discovery_endpoint { + char target[AFPC_DISCOVERY_TARGET_LEN]; + uint16_t port; + struct sockaddr_storage address; + socklen_t address_len; + unsigned int interface_index; + unsigned char *txt; + size_t txt_len; +}; + +enum afpc_discovery_event_type { + AFPC_DISCOVERY_EVENT_ADD, + AFPC_DISCOVERY_EVENT_UPDATE, + AFPC_DISCOVERY_EVENT_REMOVE, + AFPC_DISCOVERY_EVENT_ERROR, +}; + +struct afpc_discovery_event { + enum afpc_discovery_event_type type; + struct afpc_discovery_service service; + int error; + char message[AFPC_DISCOVERY_ERROR_LEN]; +}; + +/* Return values use negative errno values. afpc_discovery_next() returns one + * when an event was produced and zero when the timeout expired. */ +int afpc_discovery_open(struct afpc_discovery **discovery, + const struct afpc_discovery_options *options); +int afpc_discovery_next(struct afpc_discovery *discovery, + struct afpc_discovery_event *event, + int timeout_ms); +int afpc_discovery_resolve(struct afpc_discovery *discovery, + const struct afpc_discovery_service *service, + struct afpc_discovery_endpoint **endpoints, + size_t *endpoint_count, int timeout_ms); +int afpc_discovery_snapshot(struct afpc_discovery *discovery, + struct afpc_discovery_service **services, + size_t *service_count, int timeout_ms); +void afpc_discovery_free_services(struct afpc_discovery_service **services); +void afpc_discovery_free_endpoints( + struct afpc_discovery_endpoint **endpoints, size_t endpoint_count); +int afpc_discovery_endpoint_host( + const struct afpc_discovery_endpoint *endpoint, + char *host, size_t host_size); +void afpc_discovery_close(struct afpc_discovery **discovery); +const char *afpc_discovery_backend_name(void); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/discovery/meson.build b/discovery/meson.build new file mode 100644 index 00000000..478aa5e5 --- /dev/null +++ b/discovery/meson.build @@ -0,0 +1,36 @@ +discovery_backend_source = 'backend_' + zeroconf_backend + '.c' + +discovery_core_sources = files('discovery.c') + +discovery_lib = static_library( + 'afpc-discovery', + sources: discovery_core_sources + files(discovery_backend_source), + dependencies: [root_dependencies, zeroconf_dependencies], + include_directories: incdir, + c_args: cflags + zeroconf_cargs, + install: false, +) + +discovery_dep = declare_dependency( + link_with: discovery_lib, + dependencies: zeroconf_dependencies, + include_directories: incdir, +) + +# Higher-level discovery behavior shared by the standalone and FUSE clients. +# Keep the provider implementation separate so tests can link this archive +# against their fake discovery backends. +discovery_client_lib = static_library( + 'afpc-client-discovery', + sources: files('client/discover.c'), + dependencies: root_dependencies, + include_directories: incdir, + c_args: cflags, + install: false, +) + +discovery_client_dep = declare_dependency( + link_with: discovery_client_lib, + dependencies: discovery_dep, + include_directories: incdir, +) diff --git a/discovery/service_types.h b/discovery/service_types.h new file mode 100644 index 00000000..312b1738 --- /dev/null +++ b/discovery/service_types.h @@ -0,0 +1,10 @@ +#ifndef NETATALK_CLIENT_DISCOVERY_SERVICE_TYPES_H +#define NETATALK_CLIENT_DISCOVERY_SERVICE_TYPES_H + +/* Keep these protocol constants aligned with Netatalk's + * etc/netatalk/afp_zeroconf.h. */ +#define AFPC_DISCOVERY_AFP_SERVICE_TYPE "_afpovertcp._tcp" +#define AFPC_DISCOVERY_ADISK_SERVICE_TYPE "_adisk._tcp" +#define AFPC_DISCOVERY_DEVICE_INFO_SERVICE_TYPE "_device-info._tcp" + +#endif diff --git a/docs/DEVELOPER.md b/docs/DEVELOPER.md index ac15f8f3..8075e3b6 100644 --- a/docs/DEVELOPER.md +++ b/docs/DEVELOPER.md @@ -485,6 +485,57 @@ streaming operations, including reads, writes, metadata calls, and directory lis ---- +## Zeroconf Discovery Architecture + +Zeroconf browsing is a frontend concern implemented by two layered internal +static libraries in `discovery/`. `libafpc-discovery` normalizes the provider +API and is used by every discovery frontend. `libafpc-client-discovery` builds +the bounded discovery command and exact service resolver once from +`discovery/client/discover.c`. Discovery happens before an AFP session +or mount request exists. + + afpcmd afp_client / mount_afpfs + | | + +---------- afpc-discovery ------------+ + | + +-------+---------+ + | normalized core | + +-------+---------+ + Avahi or DNS-SD + | + selected endpoint + | + existing afpsld / afpfsd + | + authenticate -> volumes + +The public-to-the-tree API in `discovery/discovery.h` normalizes service add, +update, remove, resolve, and snapshot operations. Native providers live behind +`discovery/backend.h`: DNS-SD is preferred on macOS, Avahi is preferred on +other supported Unix systems, and a stub preserves normal builds when neither +provider is available. Service identity is the instance, registration type, +domain, and interface tuple. Resolved targets, advertised ports, address +families, IPv6 scope, and raw TXT data remain separate endpoint data. +The default provider session listens for both `_afpovertcp._tcp` and +`_device-info._tcp`; the bounded discovery frontend correlates matching +instance/domain/interface tuples and extracts the companion `model` TXT value. + +The frontends deliberately have different UX responsibilities: + +- `afpcmd --browse` maintains the live interactive service list and + consolidates advertisements sharing an instance, type, and domain. + Selection resolves every active interface member and prefers a + non-loopback endpoint before using the existing stateless connection flow. +- `afp_client discover` provides bounded human, verbose, and JSON snapshots + from the client-discovery archive. The normal table provides human-readable + information, while verbose and JSON output retain per-interface diagnostics. + Verbose output also retains every device-info advertisement as an + independent diagnostic entry. + +Fake providers in `test/` make event ordering, output, interface selection, and +ambiguity handling deterministic without requiring multicast networking in +the unit test suite. + ## afpcmd Implementation Using Stateless Library ### Overview diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 7ab1a50f..0a0f3f13 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -206,8 +206,20 @@ Appletalk: There is no support for Appletalk. There's no concept of multiple protocols, eg. doing getstatus with one protocol, then connecting with another, which is what some Apple clients do. -There's no ability to connect based on a name advertized by Bonjour/Avahi, you -need to use the IP or DNS name. +`afp_client discover` browses `_afpovertcp._tcp` and companion +`_device-info._tcp` services advertised through Bonjour/DNS-SD or Avahi. It +associates matching advertisements, extracts the device model, and resolves the +AFP target host, port, interface, and addresses. Human-readable, verbose, and +JSON output modes are available. + +`afpcmd --browse` shows a live service picker with stable selection numbers. +The picker contains advertised services and quit only; a known host +is supplied as an AFP URL on the command line. After service selection, +`afpcmd` prompts for a username and hidden password; an empty username requests +guest access. FUSE mounts can use an exact advertised name with `afp_client +mount --service`; `--volume` selects a volume to mount, while omitting it lists +the volumes available after authentication. Service lookup is non-interactive +and there is no mount browser. ## H. Server-specific information diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 4905ed81..d6030a46 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -19,6 +19,26 @@ details, run it with the '--debug' option to see detailed debug info. Note that if afpfsd is not running, afp_client or mount_afpfs will start it automatically, so in most cases you don't need to start it manually. +List AFP servers advertised on the local network through Zeroconf: + + % afp_client discover + +Use `--verbose` to include resolved addresses, raw TXT data, and separate +`_device-info._tcp` entries, or `--json` for machine-readable output. The +normal output shows the DNS-SD instance name, device model from a matching +`_device-info._tcp` advertisement, resolved target, and advertised port. + +Mount a discovered service by its exact instance name with `--service`. The +mount command resolves the address and advertised port non-interactively; use +`afpcmd --browse` for a live picker. + + % afp_client mount --service "Office File Server" --user myuser \ + --volume "File Sharing" /home/myuser/fusemount + +If `--volume` is omitted, the command authenticates with the service, prints +the volumes available to that user, and exits without starting `afpfsd`. A +trailing mountpoint is accepted but is not required in this mode. + Mount the _File Sharing_ volume from afpserver.local on /home/myuser/fusemount authenticated as user _myuser_ (you will be prompted for the password): @@ -68,7 +88,19 @@ Just run: afpcmd "afp://username@servername/volumename" -If you enter no volumename, it shows which ones are available. +To browse AFP services advertised on the local network, use `--browse`, then +select a service number: + + afpcmd --browse + +The picker lists advertised services and quit; it does not ask for a manual +host or address. After selection, enter a username and hidden password, or +leave the username blank to request guest access. Pass a known server in an AFP +URL as shown above. + +If you enter no volumename, `afpcmd` opens a numbered volume picker after +connecting. Selecting a number attaches that volume; `q` returns to the +operating-system prompt by quitting `afpcmd`. You can put a password after the username - "username:password" - but it's usually better to let it prompt you for the password so it doesn't end up in your shell history. diff --git a/docs/manpages/afp_client.1 b/docs/manpages/afp_client.1 index fdcc1acd..ef1c94b0 100644 --- a/docs/manpages/afp_client.1 +++ b/docs/manpages/afp_client.1 @@ -1,4 +1,4 @@ -.Dd January 25, 2026 +.Dd July 19, 2026 .Dt AFP_CLIENT 1 .Os Netatalk Client .Sh NAME @@ -6,15 +6,16 @@ .Nd Mount, unmount and control AFP sessions using the FUSE infrastructure .Sh SYNOPSIS .Nm -.Cm mount Ns | Ns Cm status Ns | Ns Cm unmount Ns | Ns Cm suspend Ns | Ns Cm resume Ns | Ns Cm exit +.Cm discover Ns | Ns Cm mount Ns | Ns Cm status Ns | Ns Cm unmount Ns | Ns Cm suspend Ns | Ns Cm resume Ns | Ns Cm exit .Op Ar options .Sh DESCRIPTION The .Nm -command is the user-facing control utility for AFP (Apple Filing Protocol) volumes -mounted through the Netatalk Client FUSE infrastructure. -It mounts AFP volumes, unmounts them, reports mount status, suspends and -resumes server connections, and shuts down the per-user mount manager. +command is the user-facing control utility for AFP (Apple Filing Protocol) +volumes mounted through the Netatalk Client FUSE infrastructure. +It discovers AFP servers, mounts AFP volumes, unmounts them, reports mount +status, suspends and resumes server connections, and shuts down the per-user +mount manager. .Pp Each mount request talks to a per-user manager daemon, which spawns a per-mount @@ -46,6 +47,41 @@ that can also be used for batch file transfers. Both of them use the Netatalk Client libraries. .Sh COMMANDS .Bl -tag -width Ds +.It Cm discover Oo Fl -verbose | Fl -json Oc Op Fl -timeout Ar milliseconds +Browse for AFP services advertised through Bonjour/DNS-SD or Avahi. +The default output shows each service instance, device model, resolved target, +and advertised port, and consolidates identical target/port advertisements +received on multiple interfaces. +The device model is the value of the +.Dq model +TXT item from a matching +.Sy _device-info._tcp +advertisement, or +.Dq - +when no matching item exists. +For readability, the target is displayed without its trailing DNS root dot. +.Pp +The +.Fl -verbose +option additionally shows the service type, correlated device type, domain, +interface, resolved addresses, and the DNS-SD TXT record encoded as +hexadecimal. +It also shows each +.Sy _device-info._tcp +advertisement as a separate entry, including advertisements that could not be +associated with an AFP service. +The +.Fl -json +option emits machine-readable JSON containing the provider name and resolved +service data, including the correlated +.Dq device_type +value. +These two output options cannot be combined. +.Pp +By default, discovery collects advertisements for 1500 milliseconds. +The +.Fl -timeout +option accepts a value from 0 through 60000 milliseconds. .It Cm mount Oo Ar mount options Oc Ar server:volume Ar mountpoint Using the authentication and server information provided with the mount options, mount the remote filesystem on mountpoint. @@ -57,6 +93,28 @@ If you would prefer to use the AFP URL syntax, see .Xr mount_afpfs 1 for more information. +.It Cm mount Fl -service Ar name Oo Ar mount options Oc Op Fl -volume Ar volume Op Ar mountpoint +Resolve an advertised AFP service by its exact instance name and authenticate +using the resolved address and advertised port. +With +.Fl -volume , +mount the named volume on +.Ar mountpoint . +Without +.Fl -volume , +list the volumes available to the authenticated user and exit without starting +the mount daemon. The trailing +.Ar mountpoint +is accepted but may be omitted in this mode. +Advertisements for the same +instance, domain, target, and port on multiple interfaces are treated as one +logical service. +Conflicting domains or endpoints are reported as ambiguous. +Use +.Cm discover +to inspect available names. +This command is non-interactive and does not +provide a browse mode. .It Cm unmount Ar mountpoint Remove the AFP mount that is currently connected to .Ar mountpoint . @@ -144,6 +202,18 @@ instead of the default, 548. Password to authenticate with the AFP server. If not given, or if you specify a .Sq - as the password, you will be prompted for it. This is the more secure option. +.It Fl s , Fl -service Ar name +Resolve the exact DNS-SD instance +.Ar name +before mounting. +The advertised port is used unless +.Fl -port +is also supplied explicitly. +.It Fl -volume Ar volume +Select the volume to mount when +.Fl -service +is used. +If this option is omitted, authenticate and list the available volumes instead. .It Fl P , Fl -volpass Ar password Use this if the volume you're accessing uses a volume password (a very weak form of protection as it is transferred as clear text). @@ -160,6 +230,26 @@ the client and server. Netatalk Client supports AFP 2.0 up to 3.4. .El .Sh EXAMPLES +Discover AFP servers on the local network: +.Pp +.Dl afp_client discover +.Pp +Emit detailed machine-readable discovery results: +.Pp +.Dl afp_client discover --json --timeout 2000 +.Pp +Mount an advertised service by its instance name without an interactive +picker: +.Bd -literal -offset indent +mkdir -p /mnt/shared +afp_client mount --service "Office File Server" --user user123 \ + --volume "File Sharing" /mnt/shared +.Ed +.Pp +List the volumes available to the authenticated user without mounting: +.Pp +.Dl afp_client mount --service "Office File Server" --user user123 +.Pp Mount the AFP volume .Li sharedDocs from diff --git a/docs/manpages/afpcmd.1 b/docs/manpages/afpcmd.1 index f087b15b..af3957a4 100644 --- a/docs/manpages/afpcmd.1 +++ b/docs/manpages/afpcmd.1 @@ -1,4 +1,4 @@ -.Dd June 12, 2026 +.Dd July 18, 2026 .Dt AFPCMD 1 .Os Netatalk Client .Sh NAME @@ -9,6 +9,11 @@ .Op Fl V .Op Fl v Ar loglevel .Op Fl M Ar mode +.Fl b +.Nm +.Op Fl V +.Op Fl v Ar loglevel +.Op Fl M Ar mode .Ar afp_url .Nm .Op Fl rV @@ -44,6 +49,11 @@ AFP Stateless Client Daemon running in the background to manage the AFP connecti and commands. If not already running, afpcmd will attempt to start it. .Sh OPTIONS .Bl -tag -width Ds +.It Fl b , Fl -browse +Browses AFP services advertised on the local network using DNS-SD or Avahi. +The live picker lists service names and accepts a service number or +.Cm q +to quit. It does not prompt for a host or address. .It Fl h , Fl -help Shows the help message. .It Fl r , Fl -recursive @@ -135,6 +145,19 @@ If a URL is provided on the command line, connects and enters the volume and directory specified. .Pp +If +.Fl b +is used, +.Nm +shows a live list of advertised AFP services. Selecting a service resolves its +target, advertised port, and interface-scoped address before entering the same +interactive client. +.Nm +then prompts for a username; leaving it blank requests guest access, while a +non-empty username is followed by a hidden password prompt. +To connect to a host that is not advertised, pass its AFP URL directly on the +command line. +.Pp Standard readline keystrokes are enabled. Command line completion (using tab) and history (using up and down arrows) is provided. @@ -143,7 +166,11 @@ completion is enabled. .Ss Most common commands .Bl -tag -width Ds .It Cm ls -Show files in current directory +Show files in the current directory. If no volume is attached, show a numbered +volume picker; selecting a number attaches that volume, while +.Cm q +quits +.Nm . .It Cm cd Change directories on the server .It Cm get Oo Fl r Oc Ar filename diff --git a/fuse/client.c b/fuse/client.c index b78a1547..7cb48ec2 100644 --- a/fuse/client.c +++ b/fuse/client.c @@ -39,6 +39,7 @@ #include "lib/uam_registry.h" #include "lib/utils.h" +#include "discovery/client/discover.h" #include "fuse_ipc.h" #define default_uam "Cleartxt Passwrd" @@ -56,6 +57,7 @@ static int changeuid = 0; static int changegid = 0; static char *thisbin; static int client_log_min_rank = 2; /* Default to LOG_NOTICE */ +static int list_volumes_only = 0; /* Log handler that filters by log level */ static void client_log_for_client(void *priv _U_, @@ -341,8 +343,14 @@ static void usage(void) { printf( "afp_client [options]\n" + " discover [--verbose | --json] [--timeout milliseconds]\n" + " : list Zeroconf AFP services\n" " mount [mountopts] : \n" + " mount --service [mountopts] [--volume ] [mountpoint]\n" " mount options:\n" + " -s, --service : resolve an advertised AFP service\n" + " --volume : mount this volume from the service\n" + " If omitted, list available volumes\n" " -u, --user : log in as user \n" " -p, --pass : use \n" " If password is '-', you get prompted for it\n" @@ -615,7 +623,11 @@ static int do_mount(int argc, char ** argv) int option_index = 0; struct afpfsd_ipc_mount_request request = {0}; int optnum = 0; + int port_override = 0; unsigned int uam_mask = default_uams_mask(); + const char *service_name = NULL; + const char *volume_name = NULL; + enum { OPT_VOLUME = 256 }; struct option long_options[] = { {"afpversion", 1, 0, 'v'}, {"volpass", 1, 0, 'P'}, @@ -625,10 +637,12 @@ static int do_mount(int argc, char ** argv) {"uam", 1, 0, 'a'}, {"map", 1, 0, 'm'}, {"options", 1, 0, 'O'}, + {"service", 1, 0, 's'}, + {"volume", 1, 0, OPT_VOLUME}, {0, 0, 0, 0}, }; - if (argc < 4) { + if (argc < 3) { usage(); return -1; } @@ -641,13 +655,18 @@ static int do_mount(int argc, char ** argv) while (1) { optnum++; - c = getopt_long(argc, argv, "a:m:O:o:P:p:u:v:", long_options, &option_index); + c = getopt_long(argc, argv, "a:m:O:o:P:p:s:u:v:", long_options, + &option_index); if (c == -1) { break; } switch (c) { + case OPT_VOLUME: + volume_name = optarg; + break; + case 'a': uam_mask = uam_string_to_bitmap(optarg); break; @@ -673,6 +692,7 @@ static int do_mount(int argc, char ** argv) case 'o': request.url.port = strtol(optarg, NULL, 10); + port_override = 1; break; case 'P': @@ -683,6 +703,10 @@ static int do_mount(int argc, char ** argv) snprintf(request.url.password, AFP_MAX_PASSWORD_LEN, "%s", optarg); break; + case 's': + service_name = optarg; + break; + case 'u': snprintf(request.url.username, AFP_MAX_USERNAME_LEN, "%s", optarg); break; @@ -710,6 +734,95 @@ static int do_mount(int argc, char ** argv) } } + optnum = optind; + + if (service_name) { + uint16_t service_port; + int ret; + + if (volume_name) { + if (argc - optnum != 1) { + fprintf(stderr, + "A mount point is required when --volume is specified.\n"); + return -1; + } + + if (snprintf(request.url.volumename, + sizeof(request.url.volumename), "%s", volume_name) + >= (int)sizeof(request.url.volumename)) { + fprintf(stderr, "AFP volume name is too long.\n"); + return -1; + } + } else { + if (argc - optnum > 1) { + fprintf(stderr, + "Use --volume to mount an AFP service.\n"); + return -1; + } + + list_volumes_only = 1; + } + + ret = afpc_discover_resolve_service(service_name, + request.url.servername, + sizeof(request.url.servername), + &service_port, 1500); + + if (ret == -ENOENT) { + fprintf(stderr, "No AFP service named '%s' was found.\n", + service_name); + return -1; + } + + if (ret == -EEXIST) { + fprintf(stderr, + "AFP service name '%s' is ambiguous; use afp_client " + "discover to inspect its domains and interfaces.\n", + service_name); + return -1; + } + + if (ret != 0) { + fprintf(stderr, "Could not resolve AFP service '%s': %s\n", + service_name, strerror(-ret)); + return -1; + } + + if (!port_override) { + request.url.port = service_port; + } + } else { + if (volume_name) { + fprintf(stderr, "--volume can only be used with --service.\n"); + return -1; + } + + if (argc - optnum != 2) { + fprintf(stderr, "A server:volume and mount point are required.\n"); + return -1; + } + + if (sscanf(argv[optnum++], "%[^':']:%[^':']", + request.url.servername, request.url.volumename) != 2) { + printf("Incorrect server:volume specification\n"); + return -1; + } + } + + if (uam_mask == 0) { + printf("Unknown UAM\n"); + return -1; + } + + request.uam_mask = uam_mask; + request.volume_options = DEFAULT_MOUNT_FLAGS; + + if (!list_volumes_only + && resolve_mountpoint(argv[optnum], request.mountpoint, 255) < 0) { + printf("Failed to resolve mount point\n"); + return -1; + } + /* Handle password prompts - also prompt when username is given * but password is empty (without username, guest auth is used) */ if (strcmp(request.url.password, "-") == 0 || @@ -732,7 +845,7 @@ static int do_mount(int argc, char ** argv) explicit_bzero(p, AFP_MAX_PASSWORD_LEN + 1); } - if (strcmp(request.url.volpassword, "-") == 0) { + if (!list_volumes_only && strcmp(request.url.volpassword, "-") == 0) { char *p = get_password("Volume password:"); if (p == NULL || @@ -749,38 +862,51 @@ static int do_mount(int argc, char ** argv) explicit_bzero(p, AFP_MAX_PASSWORD_LEN + 1); } - optnum = optind + 1; + memcpy(outgoing_buffer + 1, &request, sizeof(request)); + return 0; +} - if (optnum >= argc) { - printf("No volume or mount point specified\n"); +static int list_authenticated_volumes( + const struct afpfsd_ipc_mount_request *request) +{ + struct afp_connection_request connection_request; + struct afp_server *server; + memset(&connection_request, 0, sizeof(connection_request)); + connection_request.url = request->url; + connection_request.uam_mask = request->uam_mask; + + if (afp_main_quick_startup(NULL) != 0) { + fprintf(stderr, "Could not start the AFP client loop.\n"); return -1; } - if (sscanf(argv[optnum++], "%[^':']:%[^':']", - request.url.servername, request.url.volumename) != 2) { - printf("Incorrect server:volume specification\n"); - return -1; - } + afp_wait_for_started_loop(); - if (uam_mask == 0) { - printf("Unknown UAM\n"); + if (init_uams() < 0) { + fprintf(stderr, "Could not initialize AFP authentication methods.\n"); return -1; } - request.uam_mask = uam_mask; - request.volume_options = DEFAULT_MOUNT_FLAGS; + server = afp_server_full_connect(NULL, &connection_request); - if (optnum >= argc) { - printf("No mount point specified\n"); + if (server == NULL) { + fprintf(stderr, "Could not authenticate with AFP service %s.\n", + request->url.servername); return -1; } - if (resolve_mountpoint(argv[optnum], request.mountpoint, 255) < 0) { - printf("Failed to resolve mount point\n"); - return -1; + printf("Available volumes on %s:\n", request->url.servername); + + if (server->num_volumes == 0) { + printf(" (none)\n"); + } else { + for (int i = 0; i < server->num_volumes; i++) { + printf(" %s\n", server->volumes[i].volume_name_printable); + } } - memcpy(outgoing_buffer + 1, &request, sizeof(request)); + afp_logout(server, 1); + afp_server_remove(server); return 0; } @@ -993,7 +1119,7 @@ static int prepare_buffer(int argc, char * argv[]) } if (strncmp(argv[1], "mount", 5) == 0) { - return do_mount(argc, argv); + return do_mount(argc - 1, argv + 1); } else if (strncmp(argv[1], "resume", 6) == 0) { return do_resume(argc, argv); } else if (strncmp(argv[1], "suspend", 7) == 0) { @@ -1129,6 +1255,12 @@ int main(int argc, char *argv[]) const char *volumename = NULL; thisbin = argv[0]; uid = ((unsigned int) geteuid()); + + if (!strstr(argv[0], "mount_afpfs") && argc > 1 + && strcmp(argv[1], "discover") == 0) { + return afpc_discover_command(argc - 1, argv + 1); + } + /* Register logging handler to filter debug messages */ libafpclient_register(&client); @@ -1148,6 +1280,12 @@ int main(int argc, char *argv[]) return -1; } + if (list_volumes_only) { + const struct afpfsd_ipc_mount_request *req = + (const struct afpfsd_ipc_mount_request *)(outgoing_buffer + 1); + return list_authenticated_volumes(req); + } + /* Extract mountpoint and volumename for per-mount daemon routing; STATUS, SUSPEND, RESUME, EXIT go to manager, which forwards appropriately */ if (mountpoint == NULL) { diff --git a/fuse/meson.build b/fuse/meson.build index 9dd1b88c..5c80c716 100644 --- a/fuse/meson.build +++ b/fuse/meson.build @@ -1,12 +1,10 @@ -mount_afpfs_sources = ['client.c'] - executable( 'mount_afpfs', - sources: mount_afpfs_sources, + sources: 'client.c', include_directories: incdir, link_with: libafpclient, c_args: cflags, - dependencies: [root_dependencies], + dependencies: [root_dependencies, discovery_client_dep], install: true, install_rpath: install_runtime_path, ) diff --git a/meson.build b/meson.build index 05119363..023a8dcd 100644 --- a/meson.build +++ b/meson.build @@ -133,6 +133,88 @@ with_afpcmd = readline_dep.found() or editline_dep.found() with_fuse = get_option('enable-fuse') and fuse_dep.found() with_crypt = gcrypt_dep.found() +# Check for optional Zeroconf discovery support. DNS-SD is part of libSystem +# on macOS, while other implementations commonly provide libdns_sd. +zeroconf_option = get_option('zeroconf') +zeroconf_backend_option = get_option('zeroconf-backend') +avahi_dep = dependency('avahi-client', required: false) +dnssd_lib = cc.find_library('dns_sd', required: false) +dnssd_dependencies = [] + +if dnssd_lib.found() + dnssd_dependencies += dnssd_lib +endif + +have_dnssd = ( + cc.has_header('dns_sd.h') + and cc.has_function( + 'DNSServiceBrowse', + prefix: '#include ', + dependencies: dnssd_dependencies, + ) +) + +have_dnssd_getaddrinfo = ( + have_dnssd + and cc.has_function( + 'DNSServiceGetAddrInfo', + prefix: '#include ', + dependencies: dnssd_dependencies, + ) +) + +zeroconf_backend = 'stub' +zeroconf_dependencies = [] +zeroconf_cargs = [] + +if not zeroconf_option.disabled() + if zeroconf_backend_option == 'avahi' + if avahi_dep.found() + zeroconf_backend = 'avahi' + zeroconf_dependencies += avahi_dep + elif zeroconf_option.enabled() + error( + 'Avahi Zeroconf backend requested but avahi-client was not found', + ) + endif + elif zeroconf_backend_option == 'dnssd' + if have_dnssd + zeroconf_backend = 'dnssd' + zeroconf_dependencies += dnssd_dependencies + elif zeroconf_option.enabled() + error( + 'DNS-SD Zeroconf backend requested but dns_sd.h or DNSServiceBrowse was not found', + ) + endif + elif host_os == 'darwin' + if have_dnssd + zeroconf_backend = 'dnssd' + zeroconf_dependencies += dnssd_dependencies + elif avahi_dep.found() + zeroconf_backend = 'avahi' + zeroconf_dependencies += avahi_dep + endif + else + if avahi_dep.found() + zeroconf_backend = 'avahi' + zeroconf_dependencies += avahi_dep + elif have_dnssd + zeroconf_backend = 'dnssd' + zeroconf_dependencies += dnssd_dependencies + endif + endif +endif + +with_zeroconf = zeroconf_backend != 'stub' + +if zeroconf_backend == 'dnssd' and have_dnssd_getaddrinfo + zeroconf_cargs += '-DHAVE_DNSSD_GETADDRINFO' +endif + +if zeroconf_option.enabled() and not with_zeroconf + error('Zeroconf discovery enabled but neither Avahi nor DNS-SD is available') +endif + # Check for extended attribute headers (platform-dependent) if cc.has_header('sys/xattr.h') cflags += '-DHAVE_SYS_XATTR_H' @@ -234,6 +316,7 @@ if not with_afpcmd and not with_fuse endif subdir('common') +subdir('discovery') subdir('lib') subdir('daemon') subdir('cmdline') @@ -281,5 +364,7 @@ summary_info = { 'AFP command line client': with_afpcmd, 'AFP FUSE client': with_fuse, 'Encrypted UAMs': with_crypt, + 'Zeroconf discovery': with_zeroconf, + 'Zeroconf backend': zeroconf_backend, } summary(summary_info, bool_yn: true, section: 'Options:') diff --git a/meson_options.txt b/meson_options.txt index ef030629..56774170 100644 --- a/meson_options.txt +++ b/meson_options.txt @@ -26,3 +26,16 @@ option( value: false, description: 'Force FUSE v2 compatibility mode (if FUSE v3 is detected)', ) +option( + 'zeroconf', + type: 'feature', + value: 'auto', + description: 'Enable Zeroconf AFP service discovery', +) +option( + 'zeroconf-backend', + type: 'combo', + choices: ['auto', 'avahi', 'dnssd'], + value: 'auto', + description: 'Select the Zeroconf discovery provider', +) diff --git a/test/Dockerfile b/test/Dockerfile index 1d959315..76e31ecc 100644 --- a/test/Dockerfile +++ b/test/Dockerfile @@ -30,6 +30,7 @@ WORKDIR /netatalk-client-code COPY cmdline/ ./cmdline/ COPY common/ ./common/ COPY daemon/ ./daemon/ +COPY discovery/ ./discovery/ COPY fuse/ ./fuse/ COPY include/ ./include/ COPY lib/ ./lib/ diff --git a/test/check_include_boundaries.py b/test/check_include_boundaries.py index 24d71f41..27845229 100644 --- a/test/check_include_boundaries.py +++ b/test/check_include_boundaries.py @@ -7,6 +7,7 @@ RULES = { "cmdline": ("daemon/",), + "discovery": ("daemon/", "fuse/", "lib/"), "fuse": ("daemon/",), "daemon": ("fuse/",), "lib": ("daemon/",), diff --git a/test/discovery_command_fake_backend.c b/test/discovery_command_fake_backend.c new file mode 100644 index 00000000..0141ed28 --- /dev/null +++ b/test/discovery_command_fake_backend.c @@ -0,0 +1,139 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "discovery/backend.h" + +struct command_fake_context { + int emitted; +}; + +static int command_fake_start( + void **context_ptr, struct afpc_discovery *discovery, + const struct afpc_discovery_options *options) +{ + (void)discovery; + + if (options->service_type != NULL) { + return -EINVAL; + } + + *context_ptr = calloc(1, sizeof(struct command_fake_context)); + return *context_ptr ? 0 : -ENOMEM; +} + +static int command_fake_iterate(void *context_ptr, + struct afpc_discovery *discovery, + int timeout_ms) +{ + struct command_fake_context *context = context_ptr; + struct afpc_discovery_backend_event event; + (void)timeout_ms; + + if (context->emitted >= 4) { + return 0; + } + + memset(&event, 0, sizeof(event)); + event.type = AFPC_DISCOVERY_EVENT_ADD; + event.source = AFPC_DISCOVERY_SOURCE_IPV4; + memcpy(event.service.instance, "Office \"Mac\"", + sizeof("Office \"Mac\"")); + + if (context->emitted == 3) { + memcpy(event.service.instance, "Apple AFP", sizeof("Apple AFP")); + } + + if (context->emitted < 2) { + snprintf(event.service.type, sizeof(event.service.type), "%s.", + AFPC_DISCOVERY_AFP_SERVICE_TYPE); + } else { + snprintf(event.service.type, sizeof(event.service.type), "%s.", + AFPC_DISCOVERY_DEVICE_INFO_SERVICE_TYPE); + } + + memcpy(event.service.domain, "local.", sizeof("local.")); + event.service.interface_index = (unsigned int)(context->emitted % 2 + 1); + context->emitted++; + return afpc_discovery_backend_emit(discovery, &event) == 0 ? 1 : -EIO; +} + +static int command_fake_resolve( + void *context_ptr, struct afpc_discovery *discovery, + const struct afpc_discovery_service *service, + struct afpc_discovery_endpoint **endpoints, size_t *endpoint_count, + int timeout_ms) +{ + struct sockaddr_in *address; + static const unsigned char afp_txt[] = { 3, 'k', '=', 'v' }; + static const unsigned char device_txt[] = { + 13, 'm', 'o', 'd', 'e', 'l', '=', 'M', 'a', 'c', 'm', 'i', 'n', 'i', + }; + static const unsigned char apple_device_txt[] = { + 20, 'm', 'o', 'd', 'e', 'l', '=', 'M', 'a', 'c', 'B', 'o', 'o', 'k', + 'P', 'r', 'o', '1', '8', ',', '3', + }; + const unsigned char *txt; + size_t txt_len; + (void)context_ptr; + (void)discovery; + (void)timeout_ms; + *endpoints = calloc(1, sizeof(**endpoints)); + + if (!*endpoints) { + return -ENOMEM; + } + + memcpy((*endpoints)[0].target, "office.local.", + sizeof("office.local.")); + (*endpoints)[0].port = + strcasecmp(service->type, AFPC_DISCOVERY_AFP_SERVICE_TYPE) == 0 + ? 548 : 0; + address = (struct sockaddr_in *) & (*endpoints)[0].address; + address->sin_family = AF_INET; + address->sin_port = htons((*endpoints)[0].port); + inet_pton(AF_INET, "192.0.2.10", &address->sin_addr); + (*endpoints)[0].address_len = sizeof(*address); + + if (strcasecmp(service->type, + AFPC_DISCOVERY_DEVICE_INFO_SERVICE_TYPE) != 0) { + txt = afp_txt; + txt_len = sizeof(afp_txt); + } else if (strcmp(service->instance, "Apple AFP") == 0) { + txt = apple_device_txt; + txt_len = sizeof(apple_device_txt); + } else { + txt = device_txt; + txt_len = sizeof(device_txt); + } + + (*endpoints)[0].txt = malloc(txt_len); + + if (!(*endpoints)[0].txt) { + free(*endpoints); + *endpoints = NULL; + return -ENOMEM; + } + + memcpy((*endpoints)[0].txt, txt, txt_len); + (*endpoints)[0].txt_len = txt_len; + *endpoint_count = 1; + return 0; +} + +static void command_fake_stop(void *context) +{ + free(context); +} + +const struct afpc_discovery_backend_ops afpc_discovery_backend = { + .name = "fake-command", + .start = command_fake_start, + .iterate = command_fake_iterate, + .resolve = command_fake_resolve, + .stop = command_fake_stop, +}; diff --git a/test/discovery_fake_backend.c b/test/discovery_fake_backend.c new file mode 100644 index 00000000..015879bc --- /dev/null +++ b/test/discovery_fake_backend.c @@ -0,0 +1,130 @@ +#include +#include +#include +#include + +#include "discovery/backend.h" + +struct fake_context { + unsigned int step; +}; + +static void set_service(struct afpc_discovery_backend_event *event, + unsigned int interface_index) +{ + memcpy(event->service.instance, "Office\nMac", sizeof("Office\nMac")); + memcpy(event->service.type, AFPC_DISCOVERY_AFP_SERVICE_TYPE, + sizeof(AFPC_DISCOVERY_AFP_SERVICE_TYPE)); + memcpy(event->service.domain, "local.", sizeof("local.")); + event->service.interface_index = interface_index; +} + +static int fake_start(void **context_ptr, + struct afpc_discovery *discovery, + const struct afpc_discovery_options *options) +{ + (void)discovery; + (void)options; + *context_ptr = calloc(1, sizeof(struct fake_context)); + return *context_ptr ? 0 : -ENOMEM; +} + +static int fake_iterate(void *context_ptr, + struct afpc_discovery *discovery, int timeout_ms) +{ + struct fake_context *context = context_ptr; + struct afpc_discovery_backend_event event; + (void)timeout_ms; + memset(&event, 0, sizeof(event)); + + switch (context->step++) { + case 0: + event.type = AFPC_DISCOVERY_EVENT_ADD; + event.source = AFPC_DISCOVERY_SOURCE_IPV4; + set_service(&event, 1); + break; + + case 1: + event.type = AFPC_DISCOVERY_EVENT_ADD; + event.source = AFPC_DISCOVERY_SOURCE_IPV6; + set_service(&event, 1); + break; + + case 2: + event.type = AFPC_DISCOVERY_EVENT_ADD; + event.source = AFPC_DISCOVERY_SOURCE_IPV4; + set_service(&event, 2); + break; + + case 3: + event.type = AFPC_DISCOVERY_EVENT_REMOVE; + event.source = AFPC_DISCOVERY_SOURCE_IPV4; + set_service(&event, 1); + break; + + case 4: + event.type = AFPC_DISCOVERY_EVENT_REMOVE; + event.source = AFPC_DISCOVERY_SOURCE_IPV6; + set_service(&event, 1); + break; + + case 5: + event.type = AFPC_DISCOVERY_EVENT_ERROR; + event.error = EIO; + event.message = "provider\tfailure"; + break; + + default: + return 0; + } + + return afpc_discovery_backend_emit(discovery, &event) == 0 ? 1 : -EIO; +} + +static int fake_resolve(void *context_ptr, + struct afpc_discovery *discovery, + const struct afpc_discovery_service *service, + struct afpc_discovery_endpoint **endpoints, + size_t *endpoint_count, int timeout_ms) +{ + static const unsigned char txt[] = { 3, 'k', '=', 'v' }; + (void)context_ptr; + (void)discovery; + (void)service; + (void)timeout_ms; + *endpoints = calloc(1, sizeof(**endpoints)); + + if (!*endpoints) { + return -ENOMEM; + } + + memcpy((*endpoints)[0].target, "office.local.", + sizeof("office.local.")); + (*endpoints)[0].port = 1548; + (*endpoints)[0].interface_index = 1; + (*endpoints)[0].txt = malloc(sizeof(txt)); + + if (!(*endpoints)[0].txt) { + free(*endpoints); + *endpoints = NULL; + return -ENOMEM; + } + + memcpy((*endpoints)[0].txt, txt, sizeof(txt)); + (*endpoints)[0].txt_len = sizeof(txt); + *endpoint_count = 1; + return 0; +} + +static void fake_stop(void *context) +{ + free(context); +} + +const struct afpc_discovery_backend_ops afpc_discovery_backend = { + .name = "fake", + .start = fake_start, + .iterate = fake_iterate, + .resolve = fake_resolve, + .stop = fake_stop, +}; diff --git a/test/discovery_service_fake_backend.c b/test/discovery_service_fake_backend.c new file mode 100644 index 00000000..5323c7d1 --- /dev/null +++ b/test/discovery_service_fake_backend.c @@ -0,0 +1,109 @@ +#include +#include +#include +#include +#include +#include + +#include "discovery/backend.h" + +struct service_fake_context { + unsigned int next_event; +}; + +static int service_fake_start( + void **context_ptr, struct afpc_discovery *discovery, + const struct afpc_discovery_options *options) +{ + (void)discovery; + (void)options; + *context_ptr = calloc(1, sizeof(struct service_fake_context)); + return *context_ptr ? 0 : -ENOMEM; +} + +static int emit_service(struct afpc_discovery *discovery, const char *name, + const char *domain, unsigned int interface_index) +{ + struct afpc_discovery_backend_event event; + memset(&event, 0, sizeof(event)); + event.type = AFPC_DISCOVERY_EVENT_ADD; + event.source = AFPC_DISCOVERY_SOURCE_UNSPEC; + snprintf(event.service.instance, sizeof(event.service.instance), "%s", + name); + snprintf(event.service.type, sizeof(event.service.type), "%s", + AFPC_DISCOVERY_AFP_SERVICE_TYPE); + snprintf(event.service.domain, sizeof(event.service.domain), "%s", + domain); + event.service.interface_index = interface_index; + return afpc_discovery_backend_emit(discovery, &event) == 0 ? 1 : -EIO; +} + +static int service_fake_iterate(void *context_ptr, + struct afpc_discovery *discovery, + int timeout_ms) +{ + struct service_fake_context *context = context_ptr; + (void)timeout_ms; + + switch (context->next_event++) { + case 0: + return emit_service(discovery, "Multi", "local.", 1); + + case 1: + return emit_service(discovery, "Multi", "local.", 2); + + case 2: + return emit_service(discovery, "Ambiguous", "local.", 1); + + case 3: + return emit_service(discovery, "Ambiguous", "example.", 1); + + default: + return 0; + } +} + +static int service_fake_resolve( + void *context_ptr, struct afpc_discovery *discovery, + const struct afpc_discovery_service *service, + struct afpc_discovery_endpoint **endpoints, size_t *endpoint_count, + int timeout_ms) +{ + struct sockaddr_in *address; + const char *numeric_address; + (void)context_ptr; + (void)discovery; + (void)timeout_ms; + *endpoints = calloc(1, sizeof(**endpoints)); + + if (!*endpoints) { + return -ENOMEM; + } + + numeric_address = service->interface_index == 1 + ? "127.0.0.1" : "192.0.2.20"; + snprintf((*endpoints)[0].target, sizeof((*endpoints)[0].target), + "multi.local."); + (*endpoints)[0].port = 1548; + (*endpoints)[0].interface_index = service->interface_index; + address = (struct sockaddr_in *) & (*endpoints)[0].address; + address->sin_family = AF_INET; + address->sin_port = htons(1548); + inet_pton(AF_INET, numeric_address, &address->sin_addr); + (*endpoints)[0].address_len = sizeof(*address); + *endpoint_count = 1; + return 0; +} + +static void service_fake_stop(void *context) +{ + free(context); +} + +const struct afpc_discovery_backend_ops afpc_discovery_backend = { + .name = "fake-service", + .start = service_fake_start, + .iterate = service_fake_iterate, + .resolve = service_fake_resolve, + .stop = service_fake_stop, +}; diff --git a/test/meson.build b/test/meson.build index 9ea0c8aa..be96e798 100644 --- a/test/meson.build +++ b/test/meson.build @@ -23,6 +23,9 @@ public_header_checks = { } private_header_checks = { + 'discovery': 'discovery/discovery.h', + 'discovery_backend': 'discovery/backend.h', + 'discovery_client': 'discovery/client/discover.h', 'fuse_ipc': 'fuse/fuse_ipc.h', 'stateless_ipc': 'daemon/stateless_ipc.h', } @@ -85,6 +88,89 @@ test( verbose: true, ) +discovery_test = executable( + 'test_discovery', + sources: discovery_core_sources + + files( + 'discovery_fake_backend.c', + 'test_discovery.c', + ), + include_directories: incdir, + dependencies: root_dependencies, + c_args: cflags, +) + +test( + 'Zeroconf discovery core', + discovery_test, + args: ['--tap'], + protocol: 'tap', + verbose: true, +) + +discover_command_test = executable( + 'test_discover_command', + sources: discovery_core_sources + + files( + 'discovery_command_fake_backend.c', + 'test_discover_command.c', + ), + include_directories: incdir, + link_with: discovery_client_lib, + dependencies: root_dependencies, + c_args: cflags, +) + +test( + 'afp_client discover output', + discover_command_test, + args: ['--tap'], + protocol: 'tap', + verbose: true, +) + +resolve_service_test = executable( + 'test_resolve_service', + sources: discovery_core_sources + + files( + 'discovery_service_fake_backend.c', + 'test_resolve_service.c', + ), + include_directories: incdir, + link_with: discovery_client_lib, + dependencies: root_dependencies, + c_args: cflags, +) + +test( + 'afp_client service resolution', + resolve_service_test, + args: ['--tap'], + protocol: 'tap', + verbose: true, +) + +cmdline_discover_test = executable( + 'test_cmdline_discover', + sources: discovery_core_sources + + files( + '../cmdline/discover.c', + 'discovery_command_fake_backend.c', + 'test_cmdline_discover.c', + ), + include_directories: incdir, + dependencies: root_dependencies, + c_args: cflags, +) + +test( + 'afpcmd service picker', + cmdline_discover_test, + args: ['--tap'], + protocol: 'tap', + verbose: true, +) + metadata_test = executable( 'test_metadata', sources: ['test_metadata.c'], diff --git a/test/test_afpcmd_interactive.t b/test/test_afpcmd_interactive.t index 2591e5f6..50502cea 100644 --- a/test/test_afpcmd_interactive.t +++ b/test/test_afpcmd_interactive.t @@ -366,17 +366,47 @@ sub pagination_setup_commands { } # ----------------------------------------------------------------------- -# test_exit_reconnect: exit detaches volume; ls lists volumes; cd reattaches +# test_exit_reconnect: exit detaches volume and opens the picker automatically # ----------------------------------------------------------------------- { my $out = afpcmd_pipe($AFP_URL, 'exit', - 'ls', - "cd $AFP_VOL", + '1', 'quit'); like($out, qr/Detached from volume/, 'test_exit_reconnect: exit detaches'); - like($out, qr/Available volumes on/, 'test_exit_reconnect: ls shows volumes'); - like($out, qr/Attached to volume/, 'test_exit_reconnect: cd reattaches'); + like($out, qr/Available volumes on/, 'test_exit_reconnect: picker opens'); + like($out, qr/\s1\s+\Q$AFP_VOL\E/, 'test_exit_reconnect: picker numbers volume'); + like($out, qr/Attached to volume/, 'test_exit_reconnect: number reattaches'); +} + +# ----------------------------------------------------------------------- +# test_initial_volume_picker: a URL without a volume opens the picker +# ----------------------------------------------------------------------- +{ + my $server_url = "afp://${AFP_USER}:${AFP_PASS}\@${AFP_HOST}"; + my $out = afpcmd_pipe($server_url, '1', 'quit'); + like($out, qr/Available volumes on/, 'test_initial_volume_picker: picker opens'); + like($out, qr/\s1\s+\Q$AFP_VOL\E/, 'test_initial_volume_picker: volume numbered'); + like($out, qr/q\s+Quit/, 'test_initial_volume_picker: quit action labeled'); + like($out, qr/Attached to volume/, 'test_initial_volume_picker: number attaches'); + unlike($out, qr/Use 'ls' to list available volumes/, + 'test_initial_volume_picker: no obsolete help text'); +} + +# ----------------------------------------------------------------------- +# test_volume_picker_quit: only a selection number or exact q is accepted +# ----------------------------------------------------------------------- +{ + my $server_url = "afp://${AFP_USER}:${AFP_PASS}\@${AFP_HOST}"; + my $out = afpcmd_pipe($server_url, '+1', 'Q', 'not-a-number', 'q', 'pwd'); + my @errors = ($out =~ /Choose a listed volume number, or q to quit\./g); + like($out, qr/Choose a listed volume number, or q to quit\./, + 'test_volume_picker_quit: other input rejected'); + is(scalar @errors, 3, + 'test_volume_picker_quit: signed, uppercase, and command input rejected'); + like($out, qr/q\s+Quit/, 'test_volume_picker_quit: quit action labeled'); + unlike($out, qr/You're not attached to a volume/, + 'test_volume_picker_quit: q exits before later commands'); } # ----------------------------------------------------------------------- diff --git a/test/test_cmdline_discover.c b/test/test_cmdline_discover.c new file mode 100644 index 00000000..c3b97970 --- /dev/null +++ b/test/test_cmdline_discover.c @@ -0,0 +1,102 @@ +#include +#include +#include +#include + +#include "cmdline/discover.h" +#include "tap.h" + +static int count_text(const char *text, const char *needle) +{ + int count = 0; + size_t needle_len = strlen(needle); + + while ((text = strstr(text, needle))) { + count++; + text += needle_len; + } + + return count; +} + +static int capture_picker(const char *input, char *url, size_t url_size, + char *output, size_t output_size) +{ + FILE *capture_in = NULL; + FILE *capture_out = NULL; + int saved_stdin = -1; + int saved_stdout = -1; + int ret = -1; + size_t size; + capture_in = tmpfile(); + capture_out = tmpfile(); + + if (!capture_in || !capture_out || fputs(input, capture_in) == EOF) { + goto done; + } + + rewind(capture_in); + fflush(stdout); + saved_stdin = dup(STDIN_FILENO); + saved_stdout = dup(STDOUT_FILENO); + + if (saved_stdin < 0 || saved_stdout < 0 + || dup2(fileno(capture_in), STDIN_FILENO) < 0 + || dup2(fileno(capture_out), STDOUT_FILENO) < 0) { + goto done; + } + + clearerr(stdin); + ret = cmdline_discover_url(url, url_size); + fflush(stdout); +done: + + if (saved_stdin >= 0) { + dup2(saved_stdin, STDIN_FILENO); + close(saved_stdin); + clearerr(stdin); + } + + if (saved_stdout >= 0) { + dup2(saved_stdout, STDOUT_FILENO); + close(saved_stdout); + } + + if (capture_out) { + rewind(capture_out); + size = fread(output, 1, output_size - 1, capture_out); + output[size] = '\0'; + } + + if (capture_in) { + fclose(capture_in); + } + + if (capture_out) { + fclose(capture_out); + } + + return ret; +} + +int main(int argc, char **argv) +{ + char output[4096]; + char url[512]; + test_tap_init(argc, argv); + memset(url, 0, sizeof(url)); + CHECK(capture_picker("1\n", url, sizeof(url), output, + sizeof(output)) == 0); + CHECK(strcmp(url, "afp://192.0.2.10:548") == 0); + CHECK(strstr(output, "AFP servers") != NULL); + CHECK(strstr(output, "1 Office \"Mac\"") != NULL); + CHECK(count_text(output, "Office \"Mac\"") == 1); + CHECK(strstr(output, "q Quit") != NULL); + CHECK(strstr(output, "Searching for AFP services") == NULL); + CHECK(strstr(output, "manual") == NULL); + memset(url, 0, sizeof(url)); + CHECK(capture_picker("q\n", url, sizeof(url), output, + sizeof(output)) == 1); + CHECK(url[0] == '\0'); + return test_tap_finish(); +} diff --git a/test/test_discover_command.c b/test/test_discover_command.c new file mode 100644 index 00000000..21ded7f9 --- /dev/null +++ b/test/test_discover_command.c @@ -0,0 +1,114 @@ +#include +#include +#include +#include +#include + +#include "discovery/client/discover.h" +#include "tap.h" + +static int capture_command(int command_argc, char **command_argv, + char *output, size_t output_size) +{ + FILE *capture; + int saved_stdout; + int ret; + size_t size; + capture = tmpfile(); + + if (!capture) { + return -1; + } + + fflush(stdout); + saved_stdout = dup(STDOUT_FILENO); + + if (saved_stdout < 0 || dup2(fileno(capture), STDOUT_FILENO) < 0) { + fclose(capture); + return -1; + } + + ret = afpc_discover_command(command_argc, command_argv); + fflush(stdout); + dup2(saved_stdout, STDOUT_FILENO); + close(saved_stdout); + rewind(capture); + size = fread(output, 1, output_size - 1, capture); + output[size] = '\0'; + fclose(capture); + return ret; +} + +static int count_text(const char *text, const char *needle) +{ + int count = 0; + size_t needle_len = strlen(needle); + + while ((text = strstr(text, needle))) { + count++; + text += needle_len; + } + + return count; +} + +int main(int argc, char **argv) +{ + char output[4096]; + char host[256]; + uint16_t port = 0; + char *json_argv[] = { + "discover", "--json", "--timeout", "5", NULL, + }; + char *verbose_argv[] = { + "discover", "--verbose", "--timeout", "5", NULL, + }; + char *human_argv[] = { + "discover", "--timeout", "5", NULL, + }; + char *afp_client_help_argv[] = { + "discover", "--help", NULL, + }; + test_tap_init(argc, argv); + CHECK(capture_command(4, json_argv, output, sizeof(output)) == 0); + CHECK(strstr(output, "\"backend\":\"fake-command\"") != NULL); + CHECK(strstr(output, "\"name\":\"Office \\\"Mac\\\"\"") != NULL); + CHECK(strstr(output, "\"device_type\":\"Macmini\"") != NULL); + CHECK(count_text(output, "\"device_type\":\"Macmini\"") == 2); + CHECK(strstr(output, "_device-info._tcp") == NULL); + CHECK(strstr(output, "Apple AFP") == NULL); + CHECK(strstr(output, "\"target\":\"office.local.\"") != NULL); + CHECK(strstr(output, "\"port\":548") != NULL); + CHECK(strstr(output, "\"addresses\":[\"192.0.2.10\"]") != NULL); + CHECK(strstr(output, "\"txt_hex\":\"036b3d76\"") != NULL); + CHECK(capture_command(4, verbose_argv, output, sizeof(output)) == 0); + CHECK(strstr(output, "Name: Office \"Mac\"") != NULL); + CHECK(strstr(output, "Type: _afpovertcp._tcp") != NULL); + CHECK(strstr(output, "Device: Macmini") != NULL); + CHECK(count_text(output, "Type: _device-info._tcp") == 2); + CHECK(strstr(output, "Name: Apple AFP") != NULL); + CHECK(strstr(output, "Device: MacBookPro18,3") != NULL); + CHECK(strstr(output, "Target: office.local.") != NULL); + CHECK(strstr(output, "Addresses: 192.0.2.10") != NULL); + CHECK(strstr(output, "TXT (hex): 036b3d76") != NULL); + CHECK(capture_command(3, human_argv, output, sizeof(output)) == 0); + CHECK(strstr(output, "INTERFACE") == NULL); + CHECK(strstr(output, + "NAME TARGET" + " PORT MODEL") != NULL); + CHECK(strstr(output, "Macmini") != NULL); + CHECK(strstr(output, "Apple AFP") == NULL); + CHECK(count_text(output, "Office \"Mac\"") == 1); + CHECK(strstr(output, "office.local ") != NULL); + CHECK(strstr(output, "office.local.") == NULL); + CHECK(capture_command(2, afp_client_help_argv, output, + sizeof(output)) == 0); + CHECK(strstr(output, "Usage: afp_client discover ") != NULL); + CHECK(afpc_discover_resolve_service("Office \"Mac\"", host, + sizeof(host), &port, 5) == 0); + CHECK(strcmp(host, "192.0.2.10") == 0); + CHECK(port == 548); + CHECK(afpc_discover_resolve_service("Missing", host, sizeof(host), + &port, 5) == -ENOENT); + return test_tap_finish(); +} diff --git a/test/test_discovery.c b/test/test_discovery.c new file mode 100644 index 00000000..1275e8ce --- /dev/null +++ b/test/test_discovery.c @@ -0,0 +1,56 @@ +#include +#include +#include + +#include "discovery/discovery.h" +#include "tap.h" + +int main(int argc, char **argv) +{ + struct afpc_discovery *discovery = NULL; + struct afpc_discovery_event event; + struct afpc_discovery_service selected; + struct afpc_discovery_service *services = NULL; + struct afpc_discovery_endpoint *endpoints = NULL; + size_t service_count = 0; + size_t endpoint_count = 0; + int ret; + test_tap_init(argc, argv); + CHECK(strcmp(afpc_discovery_backend_name(), "fake") == 0); + CHECK(afpc_discovery_open(&discovery, NULL) == 0); + ret = afpc_discovery_next(discovery, &event, 10); + CHECK(ret == 1 && event.type == AFPC_DISCOVERY_EVENT_ADD); + CHECK(strcmp(event.service.instance, "Office?Mac") == 0); + CHECK(event.service.interface_index == 1); + selected = event.service; + /* The duplicate IPv6 announcement is coalesced; the next visible event + * is the same instance discovered on a second interface. */ + ret = afpc_discovery_next(discovery, &event, 10); + CHECK(ret == 1 && event.type == AFPC_DISCOVERY_EVENT_ADD); + CHECK(event.service.interface_index == 2); + /* Removing IPv4 does not withdraw the IPv6-backed logical service. */ + ret = afpc_discovery_next(discovery, &event, 10); + CHECK(ret == 1 && event.type == AFPC_DISCOVERY_EVENT_REMOVE); + CHECK(event.service.interface_index == 1); + ret = afpc_discovery_next(discovery, &event, 10); + CHECK(ret == 1 && event.type == AFPC_DISCOVERY_EVENT_ERROR); + CHECK(event.error == EIO); + CHECK(strcmp(event.message, "provider?failure") == 0); + CHECK(afpc_discovery_resolve(discovery, &selected, &endpoints, + &endpoint_count, 10) == 0); + CHECK(endpoint_count == 1); + CHECK(strcmp(endpoints[0].target, "office.local.") == 0); + CHECK(endpoints[0].port == 1548); + CHECK(endpoints[0].txt_len == 4); + afpc_discovery_free_endpoints(&endpoints, endpoint_count); + CHECK(endpoints == NULL); + CHECK(afpc_discovery_snapshot(discovery, &services, &service_count, + 0) == 0); + CHECK(service_count == 1); + CHECK(services[0].interface_index == 2); + afpc_discovery_free_services(&services); + CHECK(services == NULL); + afpc_discovery_close(&discovery); + CHECK(discovery == NULL); + return test_tap_finish(); +} diff --git a/test/test_resolve_service.c b/test/test_resolve_service.c new file mode 100644 index 00000000..396b2019 --- /dev/null +++ b/test/test_resolve_service.c @@ -0,0 +1,22 @@ +#include +#include +#include + +#include "discovery/client/discover.h" +#include "tap.h" + +int main(int argc, char **argv) +{ + char host[256]; + uint16_t port; + test_tap_init(argc, argv); + CHECK(afpc_discover_resolve_service("Multi", host, sizeof(host), + &port, 100) == 0); + CHECK(strcmp(host, "192.0.2.20") == 0); + CHECK(port == 1548); + CHECK(afpc_discover_resolve_service("Ambiguous", host, sizeof(host), + &port, 100) == -EEXIST); + CHECK(afpc_discover_resolve_service("Missing", host, sizeof(host), + &port, 100) == -ENOENT); + return test_tap_finish(); +}