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(); +}