diff --git a/man/3proxy.cfg.5 b/man/3proxy.cfg.5 index bb31f6d..8a9b049 100644 --- a/man/3proxy.cfg.5 +++ b/man/3proxy.cfg.5 @@ -876,9 +876,35 @@ build proxy chain. Proxies may be grouped. Proxy inside the group is selected randomly. If few groups are specified one proxy is randomly picked from each group and chain of proxies is created (that is second proxy connected through first one and so on). -Weight is used to group proxies. Weight is a number between 1 and 1000. -Weights are summed and proxies are grouped together until the weight of -the group is 1000. That is: +Weight is used to group proxies. A weight is a share of the whole, written +either as a fraction of one, anything beginning with 0 or with a point, or the +old way, in thousandths: +.br + \fB.5\fR and \fB0.5\fR and \fB500\fR are all a half +.br + \fB.333\fR and \fB333\fR are both 333 thousandths +.br + \fB1000\fR and \fB1.0\fR and \fB100%\fR are all the whole share +.br + \fB50.5%\fR and \fB.505\fR and \fB505\fR are all the same share +.br + \fB0\fR is a fallback, described below +.br + A fraction takes up to nine digits after the point, \fB.333333333\fR being +the finest share there is, and the old notation may now carry further digits +after a point of its own, so \fB123.456\fR means the same as \fB.123456\fR. +Weights are scanned as integers, nothing is read as a floating point number. +\fB1.0\fR, with as many zeroes after the point as you care to write, is the +one weight read as it looks rather than in thousandths, so that the whole +share can be written as a fraction too: a bare \fB1\fR is still a thousandth +of it, and \fB1.5\fR still one and a half of them. A weight ending in +\fB%\fR is a percentage, and takes up to seven digits after the point. +.br + Weights are summed and proxies are grouped together until the weight of +the group is the whole share. A group which falls short of it by no more than +a thousandth, which three weights of \fB333\fR do, is taken for a whole one +rather than for a group with a remainder, so a share which cannot be divided +evenly needs no adjusting by hand. That is: .br allow * .br @@ -887,7 +913,8 @@ the group is 1000. That is: parent 500 connect 192.168.10.1 3128 .br makes 3proxy to randomly choose between 2 proxies for all outgoing -connections. These 2 proxies form 1 group (summarized weight is 1000). +connections. These 2 proxies form 1 group (their weights are the whole share +between them). .br allow * * * 80 .br @@ -1020,12 +1047,37 @@ local HTTP proxy parses requests and allows only GET and POST requests. .br Optional username and password are used to authenticate on parent proxy. Username of \'*\' means username must be supplied by user. +.br + A parent which fails is taken out of the choice for the rest of that +connection, so the next attempt, see \fBparentretries\fR, goes to another +member of the group instead of the same parent again. Its share is spread over +the parents of the group which are left, in proportion to their weights. +.br + Weight 0 marks a fallback parent. Such a parent takes no share of the random +choice, and none of the share left over by a parent which failed, and is only +used once every weighted parent of its group has failed, +which is how a parent used only when another one is down is configured: +.br + allow * +.br + parent 1000 socks5 192.168.10.1 1080 +.br + parent 0 socks5 192.168.20.1 1080 +.br + Several fallbacks are tried in the order they are written. Reaching a fallback +costs an attempt, so \fBparentretries\fR has to be at least as large as the +number of parents to try. +.br + When every parent of a group has failed the request fails as well, rather +than being sent without a parent. .br .BR parentretries \fI\fR .br - Number of retries to connect to parent proxy. Default is 1. + Number of attempts to reach a parent proxy. Default is 2. Each attempt +picks a parent again, leaving out the ones which already failed, so this is +also the number of different parents a request may be tried through. .br diff --git a/src/conf.c b/src/conf.c index 227611f..de2004f 100644 --- a/src/conf.c +++ b/src/conf.c @@ -906,6 +906,82 @@ static int parserange(unsigned char *arg, uint32_t *range) return 0; } +/* Scan a parent weight into its share of WEIGHTSCALE, as an integer: there is + * no floating point anywhere near a configuration file. + * + * A weight starting with 0 or . is a fraction of one, so .333 and 0.333 are + * both a third. Anything else is the old notation, thousandths, where 1000 is + * the whole share, and it may now carry more digits after a dot: 123.456 means + * the same as .123456. Either way at most 9 digits are kept, which is the + * resolution weights are held at. + * + * 1 followed by a point and nothing but zeroes is the one weight read as it + * looks rather than as thousandths: 1.0 is the whole share, where a bare 1 is + * a thousandth of it. + * + * A weight may also be written as a percentage, which is what a trailing % + * makes it: 50.5% is .505 is 505. + */ +static int parseweight(unsigned char * s, unsigned * weight){ + static const unsigned pow10[10] = {1, 10, 100, 1000, 10000, 100000, + 1000000, 10000000, 100000000, 1000000000}; + unsigned char *p, *end; + uint64_t val = 0, res; + int ndigits = 0, atpoint = -1, after, percent = 0, fraction; + + if(!s || !*s) return 1; + end = s + strlen((char *)s); + if(end[-1] == '%'){ + percent = 1; + if(--end == s) return 1; + } + /* 1.0, with as many zeroes after it as anyone cares to write, is the one + weight read as it looks rather than as thousandths: the whole share, + where a bare 1 is a thousandth of it */ + if(!percent && s[0] == '1' && s[1] == '.' && s[2]){ + for(p = s + 2; *p == '0'; p++); + if(!*p){ + *weight = WEIGHTSCALE; + return 0; + } + } + fraction = (*s == '.' || *s == '0'); + for(p = s; p < end; p++){ + if(*p == '.'){ + if(atpoint >= 0) return 1; + atpoint = ndigits; + continue; + } + if(*p < '0' || *p > '9') return 1; + if(ndigits == 18) return 1; + val = (val * 10) + (unsigned)(*p - '0'); + ndigits++; + } + if(!ndigits || val > WEIGHTSCALE) return 1; + after = (atpoint < 0)? 0 : ndigits - atpoint; + if(percent){ + /* a hundredth of the whole share for every 1% */ + if(after > 7) return 1; + res = val * pow10[7 - after]; + } + else if(fraction){ + /* the digits before the point are the leading zero and add + nothing, so only the ones after it say what the share is */ + if(atpoint < 0) return val? 1 : (*weight = 0, 0); + if(after > 9) return 1; + res = val * pow10[9 - after]; + } + else { + /* thousandths, with the digits after the point carrying on + from them: three digits of a whole share, six more after */ + if(after > 6) return 1; + res = val * pow10[6 - after]; + } + if(res > WEIGHTSCALE) return 1; + *weight = (unsigned)res; + return 0; +} + static int h_parent(int argc, unsigned char **argv){ struct ace *acl = NULL; struct chain *chains; @@ -925,9 +1001,10 @@ static int h_parent(int argc, unsigned char **argv){ return(21); } memset(chains, 0, sizeof(struct chain)); - chains->weight = (unsigned)atoi((char *)argv[1]); - if(chains->weight == 0 || chains->weight >1000) { - fprintf(stderr, "Chaining error: bad chain weight %u line %d\n", chains->weight, linenum); + /* 0 is the fallback weight: such a parent is only used once every + weighted parent of its group has failed */ + if(parseweight(argv[1], &chains->weight)) { + fprintf(stderr, "Chaining error: bad chain weight %s line %d\n", argv[1], linenum); free(chains); return(3); } @@ -1500,7 +1577,7 @@ static int h_ace(int argc, unsigned char **argv){ return 5; } *SAPORT(&acl->chains->addr) = htons((uint16_t)atoi((char *)argv[2])); - acl->chains->weight = 1000; + acl->chains->weight = WEIGHTSCALE; case ALLOW: case DENY: if(!conf.acl){ diff --git a/src/datatypes.c b/src/datatypes.c index ddb84c3..d8c5352 100644 --- a/src/datatypes.c +++ b/src/datatypes.c @@ -802,7 +802,7 @@ static struct property prop_pwlist[] = { static struct property prop_chain[] = { {"addr", ef_chain_addr, TYPE_SA, "parent address"}, {"type", ef_chain_type, TYPE_STRING, "parent type"}, - {"weight", ef_chain_weight, TYPE_SHORT, "parent weight 0-1000"}, + {"weight", ef_chain_weight, TYPE_INTEGER, "parent weight, 1000000000 is the whole share, 0 is a fallback"}, {"user", ef_chain_user, TYPE_STRING, "parent login"}, {"password", ef_chain_password, TYPE_PASSWORD, "parent password"}, {"secure", ef_chain_secure, TYPE_INTEGER, "secure mode"}, diff --git a/src/redirect.c b/src/redirect.c index 470cdcb..bb98534 100644 --- a/src/redirect.c +++ b/src/redirect.c @@ -289,45 +289,105 @@ static void chainaddr(struct chain * cur, PROXYSOCKADDRTYPE * sa){ *sa = fresh; } +static int parentfailed(struct clientparam * param, struct chain * ch){ + int i; + + for(i = 0; i < param->nfailedparents; i++) + if(param->failedparents[i] == ch) return 1; + return 0; +} + +/* Remember a parent which could not be used for this connection, so that a + * retry picks another member of its group. The list is per connection: a + * parent which is down for one client is not taken away from the others. + */ +static void parentfail(struct clientparam * param, struct chain * ch){ + if(!ch || param->nfailedparents >= MAXFAILEDPARENTS) return; + if(parentfailed(param, ch)) return; + param->failedparents[param->nfailedparents++] = ch; +} + +/* Pick the parent to use for one group. + * + * A group is the members whose weights add up to WEIGHTSCALE, together with + * the zero weight members among them. *after is left pointing at the group + * after this one, or at NULL. A group which lands within WEIGHTFUZZ of the + * whole share counts as a whole one, so that 333 three times over is a group + * rather than a group and a remainder of a thousandth. + * + * A member which already failed for this connection is not offered again and + * its weight is given to the others, so a retry goes somewhere else. Zero + * weight members are the fallback: they are only offered once every weighted + * member of the group has failed, and they never take part in the share. + * Where the weights add up to plainly less than the whole share the remainder + * keeps its meaning of "no parent at all" and is still counted, so such a + * group can still leave the connection direct. + * + * Returns NULL when the group adds no parent. *exhausted tells the two cases + * apart: it is set when the group had parents and all of them are gone, which + * is a failure rather than a reason to connect directly. + */ +static struct chain * pickchain(struct clientparam * param, struct chain * group, + struct chain ** after, int * exhausted){ + struct chain *cur; + uint64_t total = 0, avail = 0, slack; + uint64_t r; + + *exhausted = 0; + for(cur = group; cur; cur = cur->next){ + if(total + WEIGHTFUZZ >= WEIGHTSCALE && cur->weight) break; + total += cur->weight; + if(cur->weight && !parentfailed(param, cur)) avail += cur->weight; + } + *after = cur; + + if(avail){ + slack = (total + WEIGHTFUZZ < WEIGHTSCALE)? WEIGHTSCALE - total : 0; + r = ((uint64_t)myrand() << 32 | myrand()) % (avail + slack); + for(cur = group; cur != *after; cur = cur->next){ + if(!cur->weight || parentfailed(param, cur)) continue; + if(r < cur->weight) return cur; + r -= cur->weight; + } + return NULL; + } + for(cur = group; cur != *after; cur = cur->next){ + if(!cur->weight && !parentfailed(param, cur)) return cur; + } + if(total) *exhausted = 1; + return NULL; +} + int handleredirect(struct clientparam * param, struct ace * acentry){ int connected = 0; - int weight = 1000; int res; - int done = 0; int ha = 0; struct chain * cur; + struct chain * after; struct chain * redir = NULL; - int r2; int saved = 0; if((SAISNULL(¶m->req) || !*SAPORT(¶m->req)) && param->operation != UDPASSOC) { return 100; } - r2 = (myrand()%1000); + for(cur = acentry->chains; cur; cur = after){ + struct chain * sel; + int exhausted; - for(cur = acentry->chains; cur; cur=cur->next){ - if(((weight = weight - cur->weight) > r2)|| done) { - if(weight <= 0) { - weight += 1000; - done = 0; - r2 = (myrand()%1000); - } - continue; - } + sel = pickchain(param, cur, &after, &exhausted); + /* every parent of the group is gone: connecting direct instead + would be a way around the rule, so the request fails */ + if(exhausted) return 13; + if(!sel) continue; + cur = sel; if(cur->type != R_EXTIP && cur->type != R_HA && cur->type != R_EXTPORT && cur->type != R_INTPORT) param->redirected++; - done = 1; - if(weight <= 0) { - weight += 1000; - done = 0; - r2 = (myrand()%1000); - } if(!connected){ if(cur->type == R_EXTPORT || cur->type == R_INTPORT){ if(cur->type == R_EXTPORT) param->extport = cur->range; else param->intport = cur->range; - if(cur->next)continue; + if(after)continue; return 0; } if(cur->type == R_EXTIP){ @@ -349,7 +409,7 @@ int handleredirect(struct clientparam * param, struct ace * acentry){ } } #endif - if(cur->next)continue; + if(after)continue; return 0; } else if(SAISNULL(&cur->addr) && !*SAPORT(&cur->addr)){ @@ -375,7 +435,7 @@ int handleredirect(struct clientparam * param, struct ace * acentry){ if(cur->type == R_HA){ ha = 1; } - if(cur->next)continue; + if(after)continue; if(!ha) return 0; if(param->operation == UDPASSOC) return 0; } @@ -397,6 +457,7 @@ int handleredirect(struct clientparam * param, struct ace * acentry){ saved = 1; } if((res = alwaysauth(param))){ + parentfail(param, cur); return (res >= 10)? res : 60+res; } if(ha) { @@ -420,7 +481,10 @@ int handleredirect(struct clientparam * param, struct ace * acentry){ chainaddr(cur, &next); res = (redir)?clientnegotiate(redir, param, (struct sockaddr *)&next, cur->exthost):0; - if(res) return res; + if(res) { + parentfail(param, cur); + return res; + } } redir = cur; param->redirtype = redir->type; @@ -444,6 +508,7 @@ int handleredirect(struct clientparam * param, struct ace * acentry){ if(!connected || !redir) return 0; res = clientnegotiate(redir, param, (struct sockaddr *)¶m->req, param->hostname); + if(res) parentfail(param, redir); if(saved){ SOCKET s; diff --git a/src/structures.h b/src/structures.h index c647c7f..1802564 100644 --- a/src/structures.h +++ b/src/structures.h @@ -62,6 +62,13 @@ typedef struct _3proxy_sem_s { #endif #endif #define MAXBANDLIMS 10 +#define MAXFAILEDPARENTS 16 +/* Parent weights are kept as a share of WEIGHTSCALE, which is what the + weights of a group add up to. WEIGHTFUZZ is how far short of it a group may + fall and still be taken for a whole one, a thousandth of the share: three + weights of 333, or of .333333333, otherwise leave a remainder. */ +#define WEIGHTSCALE 1000000000u +#define WEIGHTFUZZ 1000000u #ifdef WITH_POLL #include @@ -336,7 +343,7 @@ struct chain { unsigned char * exthost; unsigned char * extuser; unsigned char * extpass; - unsigned short weight; + unsigned weight; unsigned short cidr; /* local port range for extport/intport, first in the low half */ uint32_t range; @@ -764,6 +771,11 @@ struct clientparam { one connection rather than for the service, which is how a redirect and a service name standing in for a mail proxy reach tlspr. */ PROXYSERVICE starttls; + /* Parents which failed for this connection. A retry picks another + member of the group instead of the same one again, and a zero weight + member is only reached once every weighted one is in here. */ + struct chain *failedparents[MAXFAILEDPARENTS]; + int nfailedparents; }; struct filemon { diff --git a/tests/cases/parent_failover.py b/tests/cases/parent_failover.py new file mode 100644 index 0000000..d5fd726 --- /dev/null +++ b/tests/cases/parent_failover.py @@ -0,0 +1,160 @@ +"""parent: what happens to a group when one of its members is down. + +A parent which fails is taken out of the random choice for the rest of the +connection, so a retry reaches another member of the group instead of the same +dead one again. A parent of weight 0 is the fallback of its group: it is only +used once every weighted member has failed. + +The number of attempts is bounded by parentretries, two by default, so each +case here needs at most one parent to fail before a working one is reached. +""" + +import time + + +def served(server, needle, since=0): + """How many log lines carrying needle a proxy wrote past offset since.""" + return sum(1 for line in server.output()[since:].splitlines() if needle in line) + + +def wait_served(server, needle, count, since=0, timeout=5.0): + """Wait for count such lines: a session is logged once it is over, which + is a moment after the client has its answer.""" + deadline = time.time() + timeout + while time.time() < deadline: + seen = served(server, needle, since) + if seen >= count: + return seen + time.sleep(0.05) + return served(server, needle, since) + + +def run(t): + srv = t.free_port() + good = t.free_port() + spare = t.free_port() + # nothing is ever started here, so connecting to it is refused at once + dead = t.free_port() + + origin = t.start("failover_origin", f""" + log + auth iponly + allow * + http echo * /echo** + httpsrv -p{srv} + """, ports=[srv]) + + goodp = t.start("failover_good", f""" + log + auth iponly + allow * + proxy -p{good} + """, ports=[good]) + + sparep = t.start("failover_spare", f""" + log + auth iponly + allow * + proxy -p{spare} + """, ports=[spare]) + + fallback = t.free_port() + idle = t.free_port() + group = t.free_port() + allgone = t.free_port() + lone = t.free_port() + + t.start("failover_client", f""" + log + auth iponly + + # the only weighted parent is dead, the fallback has to take over + flush + allow * + parent 1000 connect 127.0.0.1 {dead} + parent 0 connect 127.0.0.1 {spare} + proxy -p{fallback} + + # the weighted parent works, so the fallback stays untouched + flush + allow * + parent 1000 connect 127.0.0.1 {good} + parent 0 connect 127.0.0.1 {spare} + proxy -p{idle} + + # one member of a group of two is dead: a retry must not pick it again + flush + allow * + parent 500 connect 127.0.0.1 {dead} + parent 500 connect 127.0.0.1 {good} + proxy -p{group} + + # nothing left to fall back to + flush + allow * + parent 1000 connect 127.0.0.1 {dead} + proxy -p{allgone} + + # a parent of weight 0 on its own is simply the parent to use + flush + allow * + parent 0 connect 127.0.0.1 {good} + proxy -p{lone} + """, ports=[fallback, idle, group, allgone, lone]) + + url = f"http://127.0.0.1:{srv}/echo" + needle = f"CONNECT 127.0.0.1:{srv}" + + # --- the fallback takes over -------------------------------------------- + mark = len(sparep.output()) + r = t.http(url, proxy=f"127.0.0.1:{fallback}") + t.eq(200, r.status, "a dead weighted parent falls back to the parent of weight 0") + t.eq(1, wait_served(sparep, needle, 1, mark), + "the fallback parent carried the request") + + # --- and only then ------------------------------------------------------ + mark = len(sparep.output()) + gmark = len(goodp.output()) + for _ in range(4): + t.eq(200, t.http(url, proxy=f"127.0.0.1:{idle}").status, + "a working weighted parent serves the request") + t.eq(4, wait_served(goodp, needle, 4, gmark), + "every request went through the weighted parent") + t.eq(0, served(sparep, needle, mark), + "the fallback is left alone while the weighted parent works") + + # --- a dead member of a weighted group ---------------------------------- + # Whichever of the two the first attempt picks, the request has to end up + # at the one that is up: the dead one is not offered to the retry again. + gmark = len(goodp.output()) + statuses = [t.http(url, proxy=f"127.0.0.1:{group}").status for _ in range(8)] + t.eq([200] * 8, statuses, + "a dead member of a group never fails a request twice over") + t.eq(8, wait_served(goodp, needle, 8, gmark), + "all of them were carried by the member which is up") + + # --- nothing left ------------------------------------------------------- + # With every parent of the group gone the request has to fail. Connecting + # direct instead would be a way around the rule that asked for a parent. + omark = len(origin.output()) + r = t.http(url, proxy=f"127.0.0.1:{allgone}") + t.ne(200, r.status, "a request fails when every parent of the group is down") + time.sleep(0.5) + t.eq(0, served(origin, "/echo", omark), + "and it is not sent direct to the origin instead") + + # --- weight 0 on its own ------------------------------------------------ + gmark = len(goodp.output()) + t.eq(200, t.http(url, proxy=f"127.0.0.1:{lone}").status, + "a parent of weight 0 alone is used like any other") + t.eq(1, wait_served(goodp, needle, 1, gmark), + "through the parent it names") + + # --- what the parser still rejects -------------------------------------- + out = t.run_config("failover_badweight", f""" + auth iponly + allow * + parent 1001 connect 127.0.0.1 {good} + proxy -p{t.free_port()} + """) + t.contains(out, "bad chain weight", "a weight above 1000 is still refused") diff --git a/tests/cases/parent_weights.py b/tests/cases/parent_weights.py new file mode 100644 index 0000000..90aac1b --- /dev/null +++ b/tests/cases/parent_weights.py @@ -0,0 +1,210 @@ +"""parent weights: the fraction notation, and what a group adds up to. + +A weight is scanned as an integer into a share of 1000000000. A weight which +starts with 0 or . is a fraction of one, .333 being a third; anything else is +the old notation, thousandths, which may now carry further digits after a dot, +so 123.456 means the same as .123456. 1.0 is the exception, read as it looks, +and a trailing % makes a weight a percentage: 50.5% is .505 is 505. + +The values are read back from the admin interface, which dumps the parsed +configuration, so what is checked is the number 3proxy holds rather than the +behaviour it happens to produce. +""" + +import re +import time + +CHAIN_WEIGHT = re.compile( + r"parent weight[^<]*= count: + return seen + time.sleep(0.05) + return served(server, needle, since) + + +def run(t): + adm = t.free_port() + prx = t.free_port() + dummy = t.free_port() + + t.start("weights_parse", f""" + auth iponly + allow * + admin -p{adm} + + flush + auth iponly + allow * + parent 1000 connect 127.0.0.1 {dummy} + parent 1.0 connect 127.0.0.1 {dummy} + parent 1.00000000000000 connect 127.0.0.1 {dummy} + parent 500 connect 127.0.0.1 {dummy} + parent 1 connect 127.0.0.1 {dummy} + parent 1.5 connect 127.0.0.1 {dummy} + parent 123.456 connect 127.0.0.1 {dummy} + parent 12.34 connect 127.0.0.1 {dummy} + parent 1.000001 connect 127.0.0.1 {dummy} + parent .333 connect 127.0.0.1 {dummy} + parent 0.333 connect 127.0.0.1 {dummy} + parent .5 connect 127.0.0.1 {dummy} + parent .333333333 connect 127.0.0.1 {dummy} + parent 0.000000001 connect 127.0.0.1 {dummy} + parent 100% connect 127.0.0.1 {dummy} + parent 50.5% connect 127.0.0.1 {dummy} + parent 50% connect 127.0.0.1 {dummy} + parent 33.3333333% connect 127.0.0.1 {dummy} + parent 1.0% connect 127.0.0.1 {dummy} + parent 0.0000001% connect 127.0.0.1 {dummy} + parent 0 connect 127.0.0.1 {dummy} + proxy -p{prx} + """, ports=[adm, prx]) + + t.eq([ + 1000000000, # 1000, the old notation for the whole share + 1000000000, # 1.0, the same share written as a fraction + 1000000000, # 1.00000000000000, zeroes past the point change nothing + 500000000, # 500 + 1000000, # 1, a thousandth + 1500000, # 1.5, one thousandth and a half of one + 123456000, # 123.456, the old notation carried further + 12340000, # 12.34 + 1000001, # 1.000001, six digits past the thousandths + 333000000, # .333 + 333000000, # 0.333, the same thing written out + 500000000, # .5 + 333333333, # .333333333, the finest the resolution goes + 1, # 0.000000001, one part of the whole + 1000000000, # 100% + 505000000, # 50.5%, the same as .505 and as 505 + 500000000, # 50% + 333333333, # 33.3333333%, seven digits past the point + 10000000, # 1.0%, a percent rather than the whole share + 1, # 0.0000001%, one part again + 0, # the fallback weight + ], weights(t, adm), "every notation is scanned into its share") + + # --- what the parser refuses ------------------------------------------ + for bad in ("1001", "1000.1", "1.0000001", ".1234567890", "01", "abc", + "1.2.3", "-1", "1e9", "101%", "50.55555555%", "%", "5%%", + "%5"): + out = t.run_config("weights_bad", f""" + auth iponly + allow * + parent {bad} connect 127.0.0.1 {dummy} + proxy -p{t.free_port()} + """) + t.contains(out, "bad chain weight", f"{bad} is refused as a weight") + + # --- a group that all but adds up --------------------------------------- + # .999999999 is one part short of the whole share. It still closes its + # group, so the parent after it is the next hop of a chain rather than + # another member of the same group. + srv = t.free_port() + first = t.free_port() + second = t.free_port() + chained = t.free_port() + grouped = t.free_port() + thirds = t.free_port() + + t.start("weights_origin", f""" + log + auth iponly + allow * + http echo * /echo** + httpsrv -p{srv} + """, ports=[srv]) + + firstp = t.start("weights_first", f""" + log + auth iponly + allow * + proxy -p{first} + """, ports=[first]) + + secondp = t.start("weights_second", f""" + log + auth iponly + allow * + proxy -p{second} + """, ports=[second]) + + t.start("weights_client", f""" + log + auth iponly + + # one part short of the whole share still ends the group + flush + allow * + parent .999999999 connect 127.0.0.1 {first} + parent 1000 connect 127.0.0.1 {second} + proxy -p{chained} + + # two halves, one written each way, are one group and one hop + flush + allow * + parent 500 connect 127.0.0.1 {first} + parent .5 connect 127.0.0.1 {second} + proxy -p{grouped} + + # three thirds are a thousandth short of the whole share and still + # make a group, so the parent after them is the next hop + flush + allow * + parent 333 connect 127.0.0.1 {first} + parent 333 connect 127.0.0.1 {first} + parent 333 connect 127.0.0.1 {first} + parent 1000 connect 127.0.0.1 {second} + proxy -p{thirds} + """, ports=[chained, grouped, thirds]) + + url = f"http://127.0.0.1:{srv}/echo" + toorigin = f"CONNECT 127.0.0.1:{srv}" + tosecond = f"CONNECT 127.0.0.1:{second}" + + fmark, smark = len(firstp.output()), len(secondp.output()) + t.eq(200, t.http(url, proxy=f"127.0.0.1:{chained}").status, + "a chain of two groups carries the request") + t.eq(1, wait_served(firstp, tosecond, 1, fmark), + "the first group's parent was asked for the second one") + t.eq(1, wait_served(secondp, toorigin, 1, smark), + "and the second group's parent reached the origin") + t.eq(0, served(firstp, toorigin, fmark), + "the first parent never went to the origin itself") + + # both members of one group talk to the origin, never to each other + fmark, smark = len(firstp.output()), len(secondp.output()) + for _ in range(8): + t.eq(200, t.http(url, proxy=f"127.0.0.1:{grouped}").status, + "a group of two halves carries the request") + t.eq(8, wait_served(firstp, toorigin, 8, fmark, timeout=0.5) + + wait_served(secondp, toorigin, 8, smark, timeout=0.5), + "each request took one hop, through either half") + t.eq(0, served(firstp, tosecond, fmark), + "the halves are one group, not a chain") + + # --- three thirds ------------------------------------------------------- + # 999000000 is within a thousandth of the whole share, so the group closes + # there. Were it left open the parent of weight 1000 would join it and + # carry about half the requests on its own, without the first hop. + fmark, smark = len(firstp.output()), len(secondp.output()) + for _ in range(8): + t.eq(200, t.http(url, proxy=f"127.0.0.1:{thirds}").status, + "a group of three thirds carries the request") + t.eq(8, wait_served(firstp, tosecond, 8, fmark), + "every request took a third as its first hop") + t.eq(8, wait_served(secondp, toorigin, 8, smark), + "and the parent after them as its second")