0

I have a machine with two network interfaces and two different internet connections. I know there are multiple routing tables and stuff like that. However I have a very easy scenario. Outgoing ssh application should always go via wlan0. So why doing so complicated stuff?

First testing with curl which does its job perfect:

curl --interface wlan0 ifconfig.me
185.107.XX.XX

curl --interface eth0 ifconfig.me
62.226.XX.XX

So without setting up any special routing rules for two interfaces, it works exactly as I want. eth0 is the default route

ip route
default via 192.168.178.1 dev eth0 proto dhcp src 192.168.178.21 metric 202
default via 172.16.1.1 dev wlan0 proto dhcp src 172.16.1.88 metric 303
172.16.1.0/24 dev wlan0 proto dhcp scope link src 172.16.1.88 metric 303
192.168.178.0/24 dev eth0 proto dhcp scope link src 192.168.178.21 metric 202

Now try to do the same with wget. Wget is ideal for debugging as it has with --bind-address the same option as ssh with -b.

wget -O- --bind-address=192.168.178.21 ifconfig.me 2> /dev/null
62.226.XX.XX

You get the same output when omitting --bind-address

wget -O- --bind-address=172.16.1.88 ifconfig.me 2> /dev/null

This command just hangs for about 9 (!) minutes and outputs nothing at the end, like ssh will do.

I know this Bind unix program to specific network interface thread. However even if the title is "Bind unix program to specific network interface" all solutions working with LD_PRELOAD bind to an IP adress. This feature is already supported by ssh, but does not help here. Firejail could solve this, but as explained in other topic has still the bug not working that way via Wifi.

So how can one really force an appliation to use a specific interface without all that complicated routing, netns or iptables rules? LD_PRELOAD looks very promising, however so far this code only focuses on changing bind IP not bindinterface.

1
  • I understand that you don't want to complicate things, but setting up extra routing rules really comes in handy when you need it.
    – iBug
    May 8 at 3:20
2

I think you're looking for (the Linux-only) SO_BINDTODEVICE. From man 7 socket:

 SO_BINDTODEVICE
      Bind this socket to a particular device like “eth0”, as specified in the passed
      interface name.  If the name is an empty string or the option length is zero,
      the socket device binding is removed. The passed option is a variable-length
      null-terminated interface name string with the maximum size of IFNAMSIZ.
      If a socket is bound to an interface, only packets received from that particular
      interface are processed by the socket.  Note that this works only for some socket
      types, particularly AF_INET sockets. It is not supported for packet sockets (use
      normal bind(2) there).

Here's a sample program that uses it:

#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <net/if.h>

int main(void)
{
    const int sockfd = socket(AF_INET, SOCK_STREAM, 0);

    if (sockfd < 0) {
        perror("socket");
        return EXIT_FAILURE;
    }

    const struct ifreq ifr = {
        .ifr_name = "enp0s3",
    };

    if (setsockopt(sockfd, SOL_SOCKET, SO_BINDTODEVICE, &ifr, sizeof(ifr)) < 0) {
        perror("setsockopt");
        return EXIT_FAILURE;
    }

    const struct sockaddr_in servaddr = {
        .sin_family      = AF_INET,
        .sin_addr.s_addr = inet_addr("142.250.73.196"),
        .sin_port        = htons(80),
    };

    if (connect(sockfd, (const struct sockaddr*) &servaddr, sizeof(servaddr)) < 0) {
        fprintf(stderr, "Connection to the server failed...\n");
        return EXIT_FAILURE;
    }

    // Make an HTTP request to Google
    dprintf(sockfd, "GET / HTTP/1.1\r\n");
    dprintf(sockfd, "HOST: www.google.com\r\n");
    dprintf(sockfd, "\r\n");

    char buffer[16] = {};
    read(sockfd, buffer, sizeof(buffer) - 1);

    printf("Response: '%s'\n", buffer);

    close(sockfd);
    return EXIT_SUCCESS;
}

The program uses SO_BINDTODEVICE to bind one of my network interfaces (enp0s3). It then connects to one of Google's servers and makes a simple HTTP request and prints the first few bytes of the response.

Here's a sample run:

$ ./a.out
Response: 'HTTP/1.1 200 OK'
0

Thanks @Andy Dalton for giving this useful information. Based upon that I've made a small piece of code for LD_PRELOAD to implement SO_BINDTODEVICE to every program.

#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <dlfcn.h>
#include <net/if.h>
#include <string.h>
#include <errno.h>

//Credits go to https://catonmat.net/simple-ld-preload-tutorial and https://catonmat.net/simple-ld-preload-tutorial-part-two
//And of course to https://unix.stackexchange.com/a/648721/334883

//compile with gcc -nostartfiles -fpic -shared bindInterface.c -o bindInterface.so -ldl -D_GNU_SOURCE
//Use with BIND_INTERFACE=<network interface> LD_PRELOAD=./bindInterface.so <your program> like curl ifconfig.me

int socket(int family, int type, int protocol)
{
    //printf("MySocket\n"); //"LD_PRELOAD=./bind.so wget -O- ifconfig.me 2> /dev/null" prints two times "MySocket". First is for DNS-Lookup. 
                            //If your first nameserver is not reachable via bound interface, 
                            //then it will try the next nameserver until it succeeds or stops with name resolution error. 
                            //This is why it could take significantly longer than curl --interface wlan0 ifconfig.me
    char *bind_addr_env;
    struct ifreq interface;
    int *(*original_socket)(int, int, int);
    original_socket = dlsym(RTLD_NEXT,"socket");
    int fd = (int)(*original_socket)(family,type,protocol);
    bind_addr_env = getenv("BIND_INTERFACE");
    int errorCode;
    if ( bind_addr_env!= NULL && strlen(bind_addr_env) > 0)
    {
        //printf(bind_addr_env);
        strcpy(interface.ifr_name,bind_addr_env);
        errorCode = setsockopt(fd, SOL_SOCKET, SO_BINDTODEVICE, &interface, sizeof(interface));
        if ( errorCode < 0)
        {
            perror("setsockopt");
            errno = EINVAL;
            return -1;
        };
    }
    else
    {
        printf("Warning: Programm with LD_PRELOAD startet, but BIND_INTERFACE environment variable not set\n");
        fprintf(stderr,"Warning: Programm with LD_PRELOAD startet, but BIND_INTERFACE environment variable not set\n");
    }

    return fd;
}

Compile it with

gcc -nostartfiles -fpic -shared bindInterface.c -o bindInterface.so -ldl -D_GNU_SOURCE

Use it with

BIND_INTERFACE=wlan0 LD_PRELOAD=./bindInterface.so wget -O- ifconfig.me 2>/dev/null

Note: Executing this can take a lot longer than with curl --interface wlan0 ifconfig.me This is because it also tries to reach your first nameserver from /etc/resolv.conf also with interface bound to. If this nameserver is not reachable it uses the second one and so on. If you edit /etc/resolv.conf and put for example Google's public DNS server 8.8.8.8 at first place, it is as fast as the curl version. With --interface option curl binds only to this interface when making the actual connection, not when resolving IP-address. So when using curl with bound interface and a privacy VPN it will leak DNS requests through normal connection when not properly configured. Use this code for verification:

#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <dlfcn.h>
#include <net/if.h>
#include <string.h>
#include <errno.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/types.h>

int connect (int sockfd, const struct sockaddr *addr, socklen_t addrlen)
{
    int *(*original_connect)(int, const struct sockaddr*, socklen_t);
    original_connect = dlsym(RTLD_NEXT,"connect");

    static struct sockaddr_in *socketAddress;
    socketAddress = (struct sockaddr_in *)addr;

    if (socketAddress -> sin_family == AF_INET)
    {
        // inet_ntoa(socketAddress->sin_addr.s_addr); when #include <arpa/inet.h> is not included
        char *dest = inet_ntoa(socketAddress->sin_addr); //with #include <arpa/inet.h>
        printf("connecting to: %s / ",dest);
    }

    struct ifreq boundInterface = 
        {
            .ifr_name = "none",
        };
    socklen_t optionlen = sizeof(boundInterface);
    int errorCode;
    errorCode = getsockopt(sockfd, SOL_SOCKET, SO_BINDTODEVICE, &boundInterface, &optionlen);
    if ( errorCode < 0)
    {
        perror("getsockopt");
        return -1;
    };
    printf("Bound Interface: %s\n",boundInterface.ifr_name);

    return (int)original_connect(sockfd, addr, addrlen);    
}

Use same option as above for compiling. Use with

LD_PRELOAD=./bindInterface.so curl --interface wlan0 ifconfig.me
connecting to: 192.168.178.1 / Bound Interface: none
connecting to: 34.117.59.81 / Bound Interface: wlan0
185.107.XX.XX

Note 2: Changing /etc/resolv.conf is not permanent. This is another topic. It was just done for demonstrating why it takes longer.

5
  • A few suggestions: (1) If you're going to replace socket(), you should comply with its API contract. If it fails, then set errno to one of the values with which socket() can fail and return -1. (2) I'd avoid writing to standard output - the caller would not expect that. If you do (1) then the caller can handle the failure. (3) There's no reason to make bind_addr_env and interface global unless you cache the values and initialize them only on the first call (which you don't do). (4) If you were to do (3) you'd want to make that initialization thread safe. May 8 at 15:43
  • Thanks for your suggestions. Regarding (1): If updated the code. Regarding setting errno: Isn't that done by setsockopt if it fails? Regarding (2): Do you mean the message "Warning:..."? I thought a lot about it. The caller should never use this library without BIND_INTERFACE beeing set. So this is a friendly reminder, that there is something wrong without terminating the program. Regarding (3): Thanks for the hint, variables aren't global anymore. Regarding (4). Since there is no initalization of global variables anymore, it should be threadsafe now. Please correct me if I'm wrong.
    – Hannes
    May 8 at 16:34
  • Re (1), yes. This is somewhat of a pedantic point, but the set of error values setsockopt sets and the set of values socket sets aren't exactly the same (e.g., a caller of socket would never expect EBADF). (2) Yes, that an the call to perror(). Who knows where standard out is going? You could just exhibit "default socket behavior` if your environment variable isn't set. May 8 at 18:11
  • Regarding (1): Could you assist me, how to do this cleanly? Regarding (2): Now default behaviour is exhibited, but warning goes to stdout and stderr. Since this program is only intended to bind to another interface and if this is not set, user should be alarmed. If updated the code again.
    – Hannes
    May 8 at 18:27
  • According to the manual setsockopt() can set errno to 1 of 5 values, three of which you're in no danger of seeing. The two remaining are EINVAL, which socket() can also set, and ENOPROTOOPT. I think it'd be safe to set errno to EINVAL if setsockopt() fails and call it a day. May 8 at 23:30

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.